diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44c38afdec6303..29d5a95ea01198 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,7 +62,7 @@ "json.schemas": [ { "fileMatch": ["homeassistant/components/*/manifest.json"], - "url": "./script/json_schemas/manifest_schema.json" + "url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json" } ] } diff --git a/.gitattributes b/.gitattributes index eca98fc228f0da..6a18819be9d01c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,14 @@ *.pcm binary Dockerfile.dev linguist-language=Dockerfile + +# Generated files +CODEOWNERS linguist-generated=true +Dockerfile linguist-generated=true +homeassistant/generated/*.py linguist-generated=true +mypy.ini linguist-generated=true +requirements.txt linguist-generated=true +requirements_all.txt linguist-generated=true +requirements_test_all.txt linguist-generated=true +requirements_test_pre_commit.txt linguist-generated=true +script/hassfest/docker/Dockerfile linguist-generated=true diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 20b1bd4c718049..6c53304a9ee3f4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: translations path: translations.tar.gz @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7bfebcd1f07bf8..fb07d60da3bcab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,8 +41,8 @@ env: UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2025.2" - DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12', '3.13']" + DEFAULT_PYTHON: "3.13" + ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support @@ -537,7 +537,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -661,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -877,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -979,14 +979,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1106,7 +1106,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1114,7 +1114,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1236,7 +1236,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1244,7 +1244,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1378,14 +1378,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 511ec963db329f..7c9a076de6422b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.0 + uses: github/codeql-action/init@v3.28.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.0 + uses: github/codeql-action/analyze@v3.28.1 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 3fffc41e60c6d6..fa3c23051909ec 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" jobs: upload: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cdf07f5c8d16da..00f0c507414a92 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -17,7 +17,7 @@ on: - "script/gen_requirements_all.py" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4568552780e8e..805e3ac4dbd8c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.9.1 hooks: - id: ruff args: @@ -61,13 +61,14 @@ repos: name: mypy entry: script/run-in-env.sh mypy language: script - types_or: [python, pyi] require_serial: true + types_or: [python, pyi] files: ^(homeassistant|pylint)/.+\.(py|pyi)$ - id: pylint name: pylint - entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y + entry: script/run-in-env.sh pylint --ignore-missing-annotations=y language: script + require_serial: true types_or: [python, pyi] files: ^(homeassistant|tests)/.+\.(py|pyi)$ - id: gen_requirements_all diff --git a/.strict-typing b/.strict-typing index f0f4e34c505f91..46b14f226609e2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -224,6 +224,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* +homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* @@ -291,6 +292,7 @@ homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* homeassistant.components.lektrico.* +homeassistant.components.letpot.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* @@ -370,6 +372,7 @@ homeassistant.components.panel_custom.* homeassistant.components.peblar.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* +homeassistant.components.person.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* @@ -383,6 +386,8 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.python_script.* +homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* homeassistant.components.rabbitair.* homeassistant.components.radarr.* diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index ace0a988bf5e64..8c57059959bd40 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,5 +1,5 @@ { - // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json + // Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], @@ -12,6 +12,7 @@ "fileMatch": [ "homeassistant/components/*/manifest.json" ], + // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync! "url": "./script/json_schemas/manifest_schema.json" } ] diff --git a/CODEOWNERS b/CODEOWNERS index 4ef40a79bd1840..09032e379fda70 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -831,6 +831,8 @@ build.json @home-assistant/supervisor /tests/components/led_ble/ @bdraco /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico +/homeassistant/components/letpot/ @jpelgrom +/tests/components/letpot/ @jpelgrom /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration @@ -1022,7 +1024,6 @@ build.json @home-assistant/supervisor /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum /homeassistant/components/nissan_leaf/ @filcole -/homeassistant/components/nmbs/ @thibmaek /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe @@ -1074,8 +1075,8 @@ build.json @home-assistant/supervisor /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm -/tests/components/onvif/ @hunterjm +/homeassistant/components/onvif/ @hunterjm @jterrace +/tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck /homeassistant/components/openai_conversation/ @balloob @@ -1190,6 +1191,8 @@ build.json @home-assistant/supervisor /tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 /tests/components/qbittorrent/ @geoffreylagaisse @finder39 +/homeassistant/components/qbus/ @Qbus-iot @thomasddn +/tests/components/qbus/ @Qbus-iot @thomasddn /homeassistant/components/qingping/ @bdraco /tests/components/qingping/ @bdraco /homeassistant/components/qld_bushfire/ @exxamalte @@ -1286,6 +1289,7 @@ build.json @home-assistant/supervisor /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/russound_rio/ @noahhusby /tests/components/russound_rio/ @noahhusby +/homeassistant/components/russound_rnet/ @noahhusby /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx @@ -1379,8 +1383,8 @@ build.json @home-assistant/supervisor /tests/components/slide_local/ @dontinelli /homeassistant/components/slimproto/ @marcelveldt /tests/components/slimproto/ @marcelveldt -/homeassistant/components/sma/ @kellerza @rklomp -/tests/components/sma/ @kellerza @rklomp +/homeassistant/components/sma/ @kellerza @rklomp @erwindouna +/tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler @@ -1626,15 +1630,15 @@ build.json @home-assistant/supervisor /tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum -/tests/components/velux/ @Julius2342 @DeerMaximum +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/Dockerfile b/Dockerfile index 630fc19496c4ac..917b9ca19c4ec4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.8 +RUN pip3 install uv==0.5.18 WORKDIR /usr/src @@ -55,7 +55,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index fc47a7d71e947e..1c2e8b0dfab817 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -308,7 +308,7 @@ def async_update_user_credentials_data( credentials.data = data self._async_schedule_save() - async def async_load(self) -> None: # noqa: C901 + async def async_load(self) -> None: """Load the users.""" if self._loaded: raise RuntimeError("Auth storage is already loaded") diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 8a6430d770a028..0edc187e24d1b0 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -4,9 +4,8 @@ import logging import types -from typing import Any, Generic +from typing import Any -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -35,12 +34,6 @@ _LOGGER = logging.getLogger(__name__) -_MultiFactorAuthModuleT = TypeVar( - "_MultiFactorAuthModuleT", - bound="MultiFactorAuthModule", - default="MultiFactorAuthModule", -) - class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" @@ -102,7 +95,9 @@ async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool raise NotImplementedError -class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]): +class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule]( + data_entry_flow.FlowHandler +): """Handler for the setup flow.""" def __init__( diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 9c2c7e500caa09..6498483a19a14d 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -17,12 +17,12 @@ __all__ = [ "POLICY_SCHEMA", - "merge_policies", - "PermissionLookup", - "PolicyType", "AbstractPermissions", - "PolicyPermissions", "OwnerPermissions", + "PermissionLookup", + "PolicyPermissions", + "PolicyType", + "merge_policies", ] diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 02f99e7bd71767..1155e77b4075c3 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -5,9 +5,8 @@ from collections.abc import Mapping import logging import types -from typing import Any, Generic +from typing import Any -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -47,8 +46,6 @@ extra=vol.ALLOW_EXTRA, ) -_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider") - class AuthProvider: """Provider of user authentication.""" @@ -195,9 +192,8 @@ async def load_auth_provider_module( return module -class LoginFlow( +class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider]( FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]], - Generic[_AuthProviderT], ): """Handler for the login flow.""" diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 57e1c734dfc1ac..3d24d807a06baf 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -119,7 +119,7 @@ def _extract_backup( Path( tempdir, "extracted", - f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}", + f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}", ), gzip=backup_meta["compressed"], key=password_to_key(restore_content.password) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 767716dbe27d58..d224b0b151d59c 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -31,7 +31,7 @@ def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool: def _check_file_allowed(mapped_args: dict[str, Any]) -> bool: # If the file is in /proc we can ignore it. args = mapped_args["args"] - path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721 + path = args[0] if type(args[0]) is str else str(args[0]) return path.startswith(ALLOWED_FILE_PREFIXES) diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index b3d57042754585..c6887d78042622 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -34,17 +34,17 @@ "services": { "capture_image": { "name": "Capture image", - "description": "Request a new image capture from a camera device.", + "description": "Requests a new image capture from a camera device.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the camera to request an image." + "description": "Entity ID of the camera to request an image from." } } }, "change_setting": { "name": "Change setting", - "description": "Change an Abode system setting.", + "description": "Changes an Abode system setting.", "fields": { "setting": { "name": "Setting", @@ -58,11 +58,11 @@ }, "trigger_automation": { "name": "Trigger automation", - "description": "Trigger an Abode automation.", + "description": "Triggers an Abode automation.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the automation to trigger." + "description": "Entity ID of the automation to trigger." } } } diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index a5daf27f445dc5..4f2e4f4f63fe49 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -70,7 +70,7 @@ async def async_reset(self) -> bool: async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: """Evaluate entities when hub reports that update has occurred.""" - LOGGER.debug("Hub {update_type.name} updated") + LOGGER.debug("Hub %s updated", update_type.name) if update_type == aiopulse.UpdateType.rollers: await update_devices(self.hass, self.config_entry, self.api.rollers) diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index b1b9c81c674e67..273ca6a772fe66 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 9e531c683da36c..f8ddeba6767dca 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -34,9 +34,12 @@ SERVICE_REMOVE_URL, ) -SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): vol.Any(cv.url, cv.path)}) SERVICE_ADD_URL_SCHEMA = vol.Schema( - {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_URL): vol.Any(cv.url, cv.path), + } ) SERVICE_REFRESH_SCHEMA = vol.Schema( {vol.Optional(CONF_FORCE, default=False): cv.boolean} diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 2ad8c2217a21e0..601b10aeb4a243 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -66,7 +66,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Motion sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} motion' + self._attr_name = f"{self._zone['name']} motion" self._attr_unique_id += "-motion" @property @@ -84,7 +84,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} myZone' + self._attr_name = f"{self._zone['name']} myZone" self._attr_unique_id += "-myzone" @property diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index bd3fa970fb9afa..ab1a1c4f9a0fb4 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -103,7 +103,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" super().__init__(instance, ac_key, zone_key=zone_key) - self._attr_name = f'{self._zone["name"]} vent' + self._attr_name = f"{self._zone['name']} vent" self._attr_unique_id += "-vent" @property @@ -131,7 +131,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} signal' + self._attr_name = f"{self._zone['name']} signal" self._attr_unique_id += "-signal" @property @@ -165,7 +165,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} temperature' + self._attr_name = f"{self._zone['name']} temperature" self._attr_unique_id += "-temp" @property diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index 70fa8a1755bf5d..a2f9440d3767d0 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -11,10 +11,10 @@ from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -37,7 +37,7 @@ async def set_configuration_source(self) -> None: await self.client.set_configuration_control(ConfigurationControl.LOCAL) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 497d4cc0488255..273ba20d6b7f59 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -137,6 +137,15 @@ class AirGradientConfigSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=lambda status: status.raw_total_volatile_organic_component, ), + AirGradientMeasurementSensorEntityDescription( + key="pm02_raw", + translation_key="raw_pm02", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_pm02, + ), ) CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 6bf7242f2f13dc..f3f78ea8fc941f 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -119,6 +119,9 @@ "raw_nitrogen": { "name": "Raw NOx" }, + "raw_pm02": { + "name": "Raw PM2.5" + }, "display_pm_standard": { "name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]", "state": { diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index f35d5c9667c4fe..1b604d72032846 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -155,8 +155,7 @@ def __init__( self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( configuration_url=( - "https://dashboard.airthings.com/devices/" - f"{airthings_device.device_id}" + f"https://dashboard.airthings.com/devices/{airthings_device.device_id}" ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 0dfd82a38c422d..248561706a3fd8 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -67,18 +67,21 @@ device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "battery": SensorEntityDescription( key="battery", @@ -86,24 +89,28 @@ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "illuminance": SensorEntityDescription( key="illuminance", translation_key="illuminance", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), } diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 6672683284368d..58ad730bc3115f 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -50,7 +50,7 @@ class AirVisualProMeasurementDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: int( history.get( - f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1 + f"Outdoor {'AQI(US)' if settings['is_aqi_usa'] else 'AQI(CN)'}", -1 ) ), translation_key="outdoor_air_quality_index", diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 5d1f9f051a36a8..39e4f73aa380d8 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -5,7 +5,14 @@ import logging from typing import Any -from aioairzone.const import AZD_MAC, AZD_WEBSERVER, DEFAULT_SYSTEM_ID +from aioairzone.const import ( + AZD_FIRMWARE, + AZD_FULL_NAME, + AZD_MAC, + AZD_MODEL, + AZD_WEBSERVER, + DEFAULT_SYSTEM_ID, +) from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -17,6 +24,7 @@ entity_registry as er, ) +from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -88,6 +96,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b entry.runtime_data = coordinator + device_registry = dr.async_get(hass) + + ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER) + if ws_data is not None: + mac = ws_data.get(AZD_MAC, "") + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, f"{entry.entry_id}_ws")}, + manufacturer=MANUFACTURER, + model=ws_data.get(AZD_MODEL), + name=ws_data.get(AZD_FULL_NAME), + sw_version=ws_data.get(AZD_FIRMWARE), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index 406fd72a6db3d9..b0a87dd4e5778b 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -10,12 +10,12 @@ from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -93,7 +93,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_ip = discovery_info.ip diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 61f79eabf5265e..59d58fb62b0354 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -68,8 +68,9 @@ def __init__( model=self.get_airzone_value(AZD_MODEL), name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id @property @@ -102,8 +103,9 @@ def __init__( manufacturer=MANUFACTURER, model="DHW", name=self.get_airzone_value(AZD_NAME), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id def get_airzone_value(self, key: str) -> Any: diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 01fde7eb2fbf95..95ed9d200f4238 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.7"] + "requirements": ["aioairzone==0.9.9"] } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 8c139d66369de8..6a0b1830b7ec44 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -474,25 +474,30 @@ def interfaces(self) -> Generator[AlexaCapability]: # If we support two modes, one being off, we allow turning on too. supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( - self.entity.domain == climate.DOMAIN - and climate.HVACMode.OFF - in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) - or self.entity.domain == climate.DOMAIN - and ( - supported_features - & ( - climate.ClimateEntityFeature.TURN_ON - | climate.ClimateEntityFeature.TURN_OFF + ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + ) + or ( + self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) ) ) - or self.entity.domain == water_heater.DOMAIN - and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + or ( + self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + ) ): yield AlexaPowerController(self.entity) - if ( - self.entity.domain == climate.DOMAIN - or self.entity.domain == water_heater.DOMAIN + if self.entity.domain == climate.DOMAIN or ( + self.entity.domain == water_heater.DOMAIN and ( supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 3eb761dacded0a..03b6a22007c6aa 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -317,9 +317,8 @@ async def _async_entity_state_listener( if should_doorbell: old_state = data["old_state"] - if ( - new_state.domain == event.DOMAIN - or new_state.state == STATE_ON + if new_state.domain == event.DOMAIN or ( + new_state.state == STATE_ON and (old_state is None or old_state.state != STATE_ON) ): await async_send_doorbell_event_message( diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index b6f5d494d0f67e..ce921072e270d7 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -21,7 +21,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_unique_id": "Impossible to determine a valid unique id for the device" + "invalid_unique_id": "Impossible to determine a valid unique ID for the device" } }, "options": { @@ -38,17 +38,17 @@ } }, "apps": { - "title": "Configure Android Apps", - "description": "Configure application id {app_id}", + "title": "Configure Android apps", + "description": "Configure application ID {app_id}", "data": { - "app_name": "Application Name", + "app_name": "Application name", "app_id": "Application ID", "app_delete": "Check to delete this application" } }, "rules": { "title": "Configure Android state detection rules", - "description": "Configure detection rule for application id {rule_id}", + "description": "Configure detection rule for application ID {rule_id}", "data": { "rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]", "rule_values": "List of state detection rules (see documentation)", diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 4df25247881771..78f24fc498ce46 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -14,7 +14,6 @@ ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,6 +30,7 @@ SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN from .helpers import create_api, get_enable_ime @@ -142,7 +142,7 @@ async def async_step_pair( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 33970171d4018b..e41cbcf9a762a0 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -44,12 +44,12 @@ } }, "apps": { - "title": "Configure Android Apps", - "description": "Configure application id {app_id}", + "title": "Configure Android apps", + "description": "Configure application ID {app_id}", "data": { - "app_name": "Application Name", + "app_name": "Application name", "app_id": "Application ID", - "app_icon": "Application Icon", + "app_icon": "Application icon", "app_delete": "Check to delete this application" } } diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 5cb92ed892ae1c..5c317755d05fbd 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -34,6 +34,7 @@ SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -204,7 +205,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle device found via zeroconf.""" if discovery_info.ip_address.version == 6: diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 50b272cc1fa8a5..58146818624f38 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -38,7 +38,7 @@ from homeassistant.util import slugify from homeassistant.util.hass_dict import HassKey -__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"] +__all__ = ["AuthorizationServer", "ClientCredential", "async_import_client_credential"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 254cc0ac789097..8a173e5e95efc7 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -50,7 +50,7 @@ async def async_setup_entry( descriptions: list[AprilaireHumidifierDescription] = [] - if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2): descriptions.append( AprilaireHumidifierDescription( key="humidifier", @@ -67,7 +67,7 @@ async def async_setup_entry( ) ) - if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: descriptions.append( AprilaireHumidifierDescription( key="dehumidifier", diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 6cce7554dd125b..ac45e352bb68fa 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.4.0"] + "requirements": ["aranet4==2.5.0"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index d7fbd0e4b3b401..b5187cba1f4bee 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -22,6 +22,7 @@ ) from homeassistant.const import ( ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_NAME, ATTR_SW_VERSION, CONCENTRATION_PARTS_PER_MILLION, @@ -142,6 +143,7 @@ def _sensor_device_info_to_hass( if adv.readings and adv.readings.name: hass_device_info[ATTR_NAME] = adv.readings.name hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME + hass_device_info[ATTR_MODEL] = adv.readings.type.model if adv.manufacturer_data: hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version) return hass_device_info diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index 6c037591688fb2..e1886a1db60867 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -9,10 +9,10 @@ from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN @@ -88,12 +88,12 @@ async def async_step_confirm( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" host = str(urlparse(discovery_info.ssdp_location).hostname) port = DEFAULT_PORT - uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + uuid = get_uniqueid_from_udn(discovery_info.upnp[ATTR_UPNP_UDN]) if not uuid: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index ec6d8a646b6ecb..cc7ecc1c426e8e 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -46,24 +46,24 @@ __all__ = ( "DOMAIN", - "async_create_default_pipeline", - "async_get_pipelines", - "async_migrate_engine", - "async_setup", - "async_pipeline_from_audio_stream", - "async_update_pipeline", + "EVENT_RECORDING", + "OPTION_PREFERRED", + "SAMPLES_PER_CHUNK", + "SAMPLE_CHANNELS", + "SAMPLE_RATE", + "SAMPLE_WIDTH", "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", "PipelineNotFound", "WakeWordSettings", - "EVENT_RECORDING", - "OPTION_PREFERRED", - "SAMPLES_PER_CHUNK", - "SAMPLE_RATE", - "SAMPLE_WIDTH", - "SAMPLE_CHANNELS", + "async_create_default_pipeline", + "async_get_pipelines", + "async_migrate_engine", + "async_pipeline_from_audio_stream", + "async_setup", + "async_update_pipeline", ) CONFIG_SCHEMA = vol.Schema( @@ -108,6 +108,7 @@ async def async_pipeline_from_audio_stream( device_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, + conversation_extra_system_prompt: str | None = None, ) -> None: """Create an audio pipeline from an audio stream. @@ -119,6 +120,7 @@ async def async_pipeline_from_audio_stream( stt_metadata=stt_metadata, stt_stream=stt_stream, wake_word_phrase=wake_word_phrase, + conversation_extra_system_prompt=conversation_extra_system_prompt, run=PipelineRun( hass, context=context, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 7dda24c4023ad0..9353bbe000796e 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -50,6 +50,7 @@ language as language_util, ulid as ulid_util, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.limited_size_dict import LimitedSizeDict from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer @@ -91,6 +92,8 @@ ("tts_engine", "tts_language"), ) +KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) + def validate_language(data: dict[str, Any]) -> Any: """Validate language settings.""" @@ -248,7 +251,7 @@ async def async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the specified stt / tts engines. """ - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store pipeline_settings = _async_resolve_default_pipeline_settings( hass, @@ -283,7 +286,7 @@ def _async_get_pipeline_from_conversation_entity( @callback def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] if pipeline_id is None: # A pipeline was not specified, use the preferred one @@ -306,7 +309,7 @@ def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> P @callback def async_get_pipelines(hass: HomeAssistant) -> list[Pipeline]: """Get all pipelines.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] return list(pipeline_data.pipeline_store.data.values()) @@ -329,7 +332,7 @@ async def async_update_pipeline( prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] updates: dict[str, Any] = pipeline.to_json() updates.pop("id") @@ -587,7 +590,7 @@ def __post_init__(self) -> None: ): raise InvalidPipelineStagesError(self.start_stage, self.end_stage) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.pipeline.id not in pipeline_data.pipeline_debug: pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS @@ -615,7 +618,7 @@ def __eq__(self, other: object) -> bool: def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" self.event_callback(event) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return @@ -650,7 +653,7 @@ async def end(self) -> None: ) ) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_data.pipeline_runs.remove_run(self) async def prepare_wake_word_detection(self) -> None: @@ -1010,16 +1013,29 @@ async def prepare_recognize_intent(self) -> None: self.intent_agent = agent_info.id async def recognize_intent( - self, intent_input: str, conversation_id: str | None, device_id: str | None + self, + intent_input: str, + conversation_id: str | None, + device_id: str | None, + conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" if self.intent_agent is None: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: - # LLMs support all languages ('*') so use pipeline language for - # intent fallback. - input_language = self.pipeline.language + # LLMs support all languages ('*') so use languages from the + # pipeline for intent fallback. + # + # We prioritize the STT and TTS languages because they may be more + # specific, such as "zh-CN" instead of just "zh". This is necessary + # for languages whose intents are split out by region when + # preferring local intent matching. + input_language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.pipeline.language + ) else: input_language = self.pipeline.conversation_language @@ -1045,10 +1061,12 @@ async def recognize_intent( device_id=device_id, language=input_language, agent_id=self.intent_agent, + extra_system_prompt=conversation_extra_system_prompt, ) processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT - conversation_result: conversation.ConversationResult | None = None + agent_id = user_input.agent_id + intent_response: intent.IntentResponse | None = None if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: # Sentence triggers override conversation agent if ( @@ -1058,14 +1076,12 @@ async def recognize_intent( ) ) is not None: # Sentence trigger matched - trigger_response = intent.IntentResponse( + agent_id = "sentence_trigger" + intent_response = intent.IntentResponse( self.pipeline.conversation_language ) - trigger_response.async_set_speech(trigger_response_text) - conversation_result = conversation.ConversationResult( - response=trigger_response, - conversation_id=user_input.conversation_id, - ) + intent_response.async_set_speech(trigger_response_text) + # Try local intents first, if preferred. elif self.pipeline.prefer_local_intents and ( intent_response := await conversation.async_handle_intents( @@ -1073,13 +1089,31 @@ async def recognize_intent( ) ): # Local intent matched + agent_id = conversation.HOME_ASSISTANT_AGENT + processed_locally = True + + # It was already handled, create response and add to chat history + if intent_response is not None: + async with conversation.async_get_chat_session( + self.hass, user_input + ) as chat_session: + speech: str = intent_response.speech.get("plain", {}).get( + "speech", "" + ) + chat_session.async_add_message( + conversation.ChatMessage( + role="assistant", + agent_id=agent_id, + content=speech, + native=intent_response, + ) + ) conversation_result = conversation.ConversationResult( response=intent_response, - conversation_id=user_input.conversation_id, + conversation_id=chat_session.conversation_id, ) - processed_locally = True - if conversation_result is None: + else: # Fall back to pipeline conversation agent conversation_result = await conversation.async_converse( hass=self.hass, @@ -1090,6 +1124,10 @@ async def recognize_intent( language=user_input.language, agent_id=user_input.agent_id, ) + speech = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( @@ -1109,10 +1147,6 @@ async def recognize_intent( ) ) - speech: str = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - return speech async def prepare_text_to_speech(self) -> None: @@ -1213,7 +1247,7 @@ def _capture_chunk(self, audio_bytes: bytes | None) -> None: return # Forward to device audio capture - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] audio_queue = pipeline_data.device_audio_queues.get(self._device_id) if audio_queue is None: return @@ -1392,8 +1426,13 @@ class PipelineInput: """Input for text-to-speech. Required when start_stage = tts.""" conversation_id: str | None = None + """Identifier for the conversation.""" + + conversation_extra_system_prompt: str | None = None + """Extra prompt information for the conversation agent.""" device_id: str | None = None + """Identifier of the device that is processing the input/output of the pipeline.""" async def execute(self) -> None: """Run pipeline.""" @@ -1453,9 +1492,9 @@ async def execute(self) -> None: if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> ( - AsyncGenerator[EnhancedAudioChunk] - ): + async def buffer_then_audio_stream() -> AsyncGenerator[ + EnhancedAudioChunk + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk @@ -1483,6 +1522,7 @@ async def buffer_then_audio_stream() -> ( intent_input, self.conversation_id, self.device_id, + self.conversation_extra_system_prompt, ) if tts_input.strip(): current_stage = PipelineStage.TTS @@ -1864,7 +1904,7 @@ async def _async_migrate_func( return old_data -@singleton(DOMAIN) +@singleton(KEY_ASSIST_PIPELINE, async_=True) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index c7e4846aad73c3..a590f30fc7a11f 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state -from .const import DOMAIN, OPTION_PREFERRED -from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection +from .const import OPTION_PREFERRED +from .pipeline import KEY_ASSIST_PIPELINE, AssistDevice from .vad import VadSensitivity @@ -30,7 +30,7 @@ def get_chosen_pipeline( if state is None or state.state == OPTION_PREFERRED: return None - pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store + pipeline_store = hass.data[KEY_ASSIST_PIPELINE].pipeline_store return next( (item.id for item in pipeline_store.async_items() if item.name == state.state), None, @@ -80,7 +80,7 @@ async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store self.async_on_remove( pipeline_store.async_add_change_set_listener(self._pipelines_updated) @@ -116,9 +116,7 @@ async def _pipelines_updated( @callback def _update_options(self) -> None: """Handle pipeline update.""" - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store + pipeline_store = self.hass.data[KEY_ASSIST_PIPELINE].pipeline_store options = [OPTION_PREFERRED] options.extend(sorted(item.name for item in pipeline_store.async_items())) self._attr_options = options diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index c96af655589934..e8da8e56fd6474 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -1,9 +1,6 @@ """Assist pipeline Websocket API.""" import asyncio - -# Suppressing disable=deprecated-module is needed for Python 3.11 -import audioop # pylint: disable=deprecated-module import base64 from collections.abc import AsyncGenerator, Callable import contextlib @@ -11,6 +8,8 @@ import math from typing import Any, Final +# Suppressing disable=deprecated-module is needed for Python 3.11 +import audioop # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api @@ -22,7 +21,6 @@ from .const import ( DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, - DOMAIN, EVENT_RECORDING, SAMPLE_CHANNELS, SAMPLE_RATE, @@ -30,9 +28,9 @@ ) from .error import PipelineNotFound from .pipeline import ( + KEY_ASSIST_PIPELINE, AudioSettings, DeviceAudioQueue, - PipelineData, PipelineError, PipelineEvent, PipelineEventType, @@ -284,7 +282,7 @@ def websocket_list_runs( msg: dict[str, Any], ) -> None: """List pipeline runs for which debug data is available.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] if pipeline_id not in pipeline_data.pipeline_debug: @@ -320,7 +318,7 @@ def websocket_list_devices( msg: dict[str, Any], ) -> None: """List assist devices.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] ent_reg = er.async_get(hass) connection.send_result( msg["id"], @@ -351,7 +349,7 @@ def websocket_get_run( msg: dict[str, Any], ) -> None: """Get debug data for a pipeline run.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] @@ -456,7 +454,7 @@ async def websocket_device_capture( msg: dict[str, Any], ) -> None: """Capture raw audio from a satellite device and forward to client.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] device_id = msg["device_id"] # Number of seconds to record audio in wall clock time diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index dd940e8cdbe57d..47b0123a2448dd 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -30,8 +30,8 @@ __all__ = [ "DOMAIN", "AssistSatelliteAnnouncement", - "AssistSatelliteEntity", "AssistSatelliteConfiguration", + "AssistSatelliteEntity", "AssistSatelliteEntityDescription", "AssistSatelliteEntityFeature", "AssistSatelliteWakeWord", diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index ba8b54f7da2e64..8be136653ba593 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -187,47 +187,10 @@ async def async_internal_announce( """ await self._cancel_running_pipeline() - media_id_source: Literal["url", "media_id", "tts"] | None = None - if message is None: message = "" - if not media_id: - media_id_source = "tts" - # Synthesize audio and get URL - pipeline_id = self._resolve_pipeline() - pipeline = async_get_pipeline(self.hass, pipeline_id) - - tts_options: dict[str, Any] = {} - if pipeline.tts_voice is not None: - tts_options[tts.ATTR_VOICE] = pipeline.tts_voice - - if self.tts_options is not None: - tts_options.update(self.tts_options) - - media_id = tts_generate_media_source_id( - self.hass, - message, - engine=pipeline.tts_engine, - language=pipeline.tts_language, - options=tts_options, - ) - - if media_source.is_media_source_id(media_id): - if not media_id_source: - media_id_source = "media_id" - media = await media_source.async_resolve_media( - self.hass, - media_id, - None, - ) - media_id = media.url - - if not media_id_source: - media_id_source = "url" - - # Resolve to full URL - media_id = async_process_play_media_url(self.hass, media_id) + announcement = await self._resolve_announcement_media_id(message, media_id) if self._is_announcing: raise SatelliteBusyError @@ -237,9 +200,7 @@ async def async_internal_announce( try: # Block until announcement is finished - await self.async_announce( - AssistSatelliteAnnouncement(message, media_id, media_id_source) - ) + await self.async_announce(announcement) finally: self._is_announcing = False self._set_state(AssistSatelliteState.IDLE) @@ -428,3 +389,48 @@ def _resolve_vad_sensitivity(self) -> float: vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state) return vad.VadSensitivity.to_seconds(vad_sensitivity) + + async def _resolve_announcement_media_id( + self, message: str, media_id: str | None + ) -> AssistSatelliteAnnouncement: + """Resolve the media ID.""" + media_id_source: Literal["url", "media_id", "tts"] | None = None + + if not media_id: + media_id_source = "tts" + # Synthesize audio and get URL + pipeline_id = self._resolve_pipeline() + pipeline = async_get_pipeline(self.hass, pipeline_id) + + tts_options: dict[str, Any] = {} + if pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = pipeline.tts_voice + + if self.tts_options is not None: + tts_options.update(self.tts_options) + + media_id = tts_generate_media_source_id( + self.hass, + message, + engine=pipeline.tts_engine, + language=pipeline.tts_language, + options=tts_options, + ) + + if media_source.is_media_source_id(media_id): + if not media_id_source: + media_id_source = "media_id" + media = await media_source.async_resolve_media( + self.hass, + media_id, + None, + ) + media_id = media.url + + if not media_id_source: + media_id_source = "url" + + # Resolve to full URL + media_id = async_process_play_media_url(self.hass, media_id) + + return AssistSatelliteAnnouncement(message, media_id, media_id_source) diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py new file mode 100644 index 00000000000000..75396cf138f50e --- /dev/null +++ b/homeassistant/components/assist_satellite/intent.py @@ -0,0 +1,69 @@ +"""Assist Satellite intents.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from .const import DOMAIN, AssistSatelliteEntityFeature + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + intent.async_register(hass, BroadcastIntentHandler()) + + +class BroadcastIntentHandler(intent.IntentHandler): + """Broadcast a message.""" + + intent_type = intent.INTENT_BROADCAST + description = "Broadcast a message through the home" + + @property + def slot_schema(self) -> dict | None: + """Return a slot schema.""" + return {vol.Required("message"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Broadcast a message.""" + hass = intent_obj.hass + ent_reg = er.async_get(hass) + + # Find all assist satellite entities that are not the one invoking the intent + entities = { + entity: entry + for entity in hass.states.async_entity_ids(DOMAIN) + if (entry := ent_reg.async_get(entity)) + and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + } + + if intent_obj.device_id: + entities = { + entity: entry + for entity, entry in entities.items() + if entry.device_id != intent_obj.device_id + } + + await hass.services.async_call( + DOMAIN, + "announce", + {"message": intent_obj.slots["message"]["value"]}, + blocking=True, + context=intent_obj.context, + target={"entity_id": list(entities)}, + ) + + response = intent_obj.create_response() + response.async_set_speech("Done") + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + id=entity, + name=state.name if (state := hass.states.get(entity)) else entity, + ) + for entity in entities + ] + ) + return response diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index bab40f281f59b8..9d50f50c7e92bd 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -31,8 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "invalid_unique_id": "Impossible to determine a valid unique id for the device", - "no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible" + "invalid_unique_id": "Impossible to determine a valid unique ID for the device", + "no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible" } }, "options": { @@ -42,7 +42,7 @@ "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", "interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)", - "dnsmasq": "The location in the router of the dnsmasq.leases files", + "dnsmasq": "The location of the dnsmasq.leases file in the router", "require_ip": "Devices must have IP (for access point mode)" } } diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 456b8962461303..ea402f03b0ea42 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "iot_class": "cloud_polling", "loggers": ["aussiebb"], - "requirements": ["pyaussiebb==0.1.4"] + "requirements": ["pyaussiebb==0.1.5"] } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index bd8af526d7583c..955a6215096eb4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -636,9 +636,9 @@ async def async_trigger( alias = "" if "trigger" in run_variables: if "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" if "alias" in run_variables["trigger"]: - alias = f' trigger \'{run_variables["trigger"]["alias"]}\'' + alias = f" trigger '{run_variables['trigger']['alias']}'" self._logger.debug("Automation%s triggered%s", alias, reason) # Create a new context referring to the old context. diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 88985b0db10d54..429187e1f5baca 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -11,11 +11,12 @@ from python_awair.user import AwairUser import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -29,7 +30,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): host: str async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 592b1e2d41f7eb..9f801882387f0b 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_REAUTH, @@ -32,6 +31,14 @@ ) from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from homeassistant.util.network import is_link_local @@ -190,7 +197,7 @@ async def _redo_configuration( return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Axis device.""" return await self._process_discovered_device( @@ -203,21 +210,21 @@ async def async_step_dhcp( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a SSDP discovered Axis device.""" - url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL]) + url = urlsplit(discovery_info.upnp[ATTR_UPNP_PRESENTATION_URL]) return await self._process_discovered_device( { CONF_HOST: url.hostname, - CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]), - CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}", + CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]), + CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}", CONF_PORT: url.port, } ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a Zeroconf discovered Axis device.""" return await self._process_discovered_device( diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json index c8ec158a844543..b4734ad2262242 100644 --- a/homeassistant/components/azure_data_explorer/strings.json +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -2,10 +2,10 @@ "config": { "step": { "user": { - "title": "Setup your Azure Data Explorer integration", + "title": "Set up Azure Data Explorer", "description": "Enter connection details", "data": { - "cluster_ingest_uri": "Cluster Ingest URI", + "cluster_ingest_uri": "Cluster ingestion URI", "authority_id": "Authority ID", "client_id": "Client ID", "client_secret": "Client secret", @@ -14,7 +14,7 @@ "use_queued_ingestion": "Use queued ingestion" }, "data_description": { - "cluster_ingest_uri": "Ingest-URI of the cluster", + "cluster_ingest_uri": "Ingestion URI of the cluster", "use_queued_ingestion": "Must be enabled when using ADX free cluster" } } diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json index d17c4a385c04f6..8ec559ac8b7a39 100644 --- a/homeassistant/components/azure_event_hub/strings.json +++ b/homeassistant/components/azure_event_hub/strings.json @@ -2,26 +2,26 @@ "config": { "step": { "user": { - "title": "Set up your Azure Event Hub integration", + "title": "Set up Azure Event Hub", "data": { - "event_hub_instance_name": "Event Hub Instance Name", - "use_connection_string": "Use Connection String" + "event_hub_instance_name": "Event Hub instance name", + "use_connection_string": "Use connection string" } }, "conn_string": { - "title": "Connection String method", + "title": "Connection string method", "description": "Please enter the connection string for: {event_hub_instance_name}", "data": { - "event_hub_connection_string": "Event Hub Connection String" + "event_hub_connection_string": "Event Hub connection string" } }, "sas": { - "title": "SAS Credentials method", + "title": "SAS credentials method", "description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}", "data": { - "event_hub_namespace": "Event Hub Namespace", - "event_hub_sas_policy": "Event Hub SAS Policy", - "event_hub_sas_key": "Event Hub SAS Key" + "event_hub_namespace": "Event Hub namespace", + "event_hub_sas_policy": "Event Hub SAS policy", + "event_hub_sas_key": "Event Hub SAS key" } } }, @@ -38,7 +38,7 @@ "options": { "step": { "init": { - "title": "Options for the Azure Event Hub.", + "title": "Options for Azure Event Hub.", "data": { "send_interval": "Interval between sending batches to the hub." } diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 00b226a9fee102..93cadcfb2f3b83 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -27,6 +27,7 @@ IncorrectPasswordError, ManagerBackup, NewBackup, + RestoreBackupEvent, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -35,7 +36,6 @@ __all__ = [ "AddonInfo", "AgentBackup", - "ManagerBackup", "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", @@ -46,7 +46,9 @@ "Folder", "IncorrectPasswordError", "LocalBackupAgent", + "ManagerBackup", "NewBackup", + "RestoreBackupEvent", "WrittenBackup", ] diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 3c5d5d39f7ea5c..997813eca21219 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -5,8 +5,10 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass, field, replace +import datetime as dt from datetime import datetime, timedelta from enum import StrEnum +import random from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim @@ -22,11 +24,17 @@ if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup -# The time of the automatic backup event should be compatible with -# the time of the recorder's nightly job which runs at 04:12. -# Run the backup at 04:45. -CRON_PATTERN_DAILY = "45 4 * * *" -CRON_PATTERN_WEEKLY = "45 4 * * {}" +CRON_PATTERN_DAILY = "{m} {h} * * *" +CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" + +# The default time for automatic backups to run is at 04:45. +# This time is chosen to be compatible with the time of the recorder's +# nightly job which runs at 04:12. +DEFAULT_BACKUP_TIME = dt.time(4, 45) + +# Randomize the start time of the backup by up to 60 minutes to avoid +# all backups running at the same time. +BACKUP_START_TIME_JITTER = 60 * 60 class StoredBackupConfig(TypedDict): @@ -69,6 +77,11 @@ def from_dict(cls, data: StoredBackupConfig) -> Self: else: last_completed = None + if time_str := data["schedule"]["time"]: + time = dt_util.parse_time(time_str) + else: + time = None + return cls( create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -85,7 +98,9 @@ def from_dict(cls, data: StoredBackupConfig) -> Self: copies=retention["copies"], days=retention["days"], ), - schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])), + schedule=BackupSchedule( + state=ScheduleState(data["schedule"]["state"]), time=time + ), ) def to_dict(self) -> StoredBackupConfig: @@ -132,7 +147,7 @@ async def update( *, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, - schedule: ScheduleState | UndefinedType = UNDEFINED, + schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" if create_backup is not UNDEFINED: @@ -143,7 +158,7 @@ async def update( self.data.retention = new_retention self.data.retention.apply(self._manager) if schedule is not UNDEFINED: - new_schedule = BackupSchedule(state=schedule) + new_schedule = BackupSchedule(**schedule) if new_schedule.to_dict() != self.data.schedule.to_dict(): self.data.schedule = new_schedule self.data.schedule.apply(self._manager) @@ -238,10 +253,18 @@ class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" state: ScheduleState + time: str | None + + +class ScheduleParametersDict(TypedDict, total=False): + """Represent parameters for backup schedule.""" + + state: ScheduleState + time: dt.time | None class ScheduleState(StrEnum): - """Represent the schedule state.""" + """Represent the schedule recurrence.""" NEVER = "never" DAILY = "daily" @@ -259,7 +282,9 @@ class BackupSchedule: """Represent the backup schedule.""" state: ScheduleState = ScheduleState.NEVER + time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) + next_automatic_backup: datetime | None = field(init=False, default=None) @callback def apply( @@ -274,11 +299,17 @@ def apply( self._unschedule_next(manager) return + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME if self.state is ScheduleState.DAILY: - self._schedule_next(CRON_PATTERN_DAILY, manager) + self._schedule_next( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), + manager, + ) else: self._schedule_next( - CRON_PATTERN_WEEKLY.format(self.state.value), + CRON_PATTERN_WEEKLY.format( + m=time.minute, h=time.hour, d=self.state.value + ), manager, ) @@ -299,7 +330,10 @@ def _schedule_next( if next_time < now: # schedule a backup at next daily time once # if we missed the last scheduled backup - cron_event = CronSim(CRON_PATTERN_DAILY, now) + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME + cron_event = CronSim( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now + ) next_time = next(cron_event) # reseed the cron event attribute # add a day to the next time to avoid scheduling at the same time again @@ -329,17 +363,27 @@ async def _create_backup(now: datetime) -> None: except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") + if self.time is None: + # randomize the start time of the backup by up to 60 minutes if the time is + # not set to avoid all backups running at the same time + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + LOGGER.debug("Scheduling next automatic backup at %s", next_time) + self.next_automatic_backup = next_time manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) def to_dict(self) -> StoredBackupSchedule: """Convert backup schedule to a dict.""" - return StoredBackupSchedule(state=self.state) + return StoredBackupSchedule( + state=self.state, + time=self.time.isoformat() if self.time else None, + ) @callback def _unschedule_next(self, manager: BackupManager) -> None: """Unschedule the next backup.""" + self.next_automatic_backup = None if (remove_next_event := manager.remove_next_backup_event) is not None: remove_next_event() manager.remove_next_backup_event = None diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 73a8c8eb60286d..b909b2728a7971 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -4,18 +4,23 @@ import asyncio from http import HTTPStatus -from typing import cast +import threading +from typing import IO, cast from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.web import FileResponse, Request, Response, StreamResponse +from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify +from . import util +from .agent import BackupAgent from .const import DATA_MANAGER +from .manager import BackupManager @callback @@ -43,8 +48,13 @@ async def get( agent_id = request.query.getone("agent_id") except KeyError: return Response(status=HTTPStatus.BAD_REQUEST) + try: + password = request.query.getone("password") + except KeyError: + password = None - manager = request.app[KEY_HASS].data[DATA_MANAGER] + hass = request.app[KEY_HASS] + manager = hass.data[DATA_MANAGER] if agent_id not in manager.backup_agents: return Response(status=HTTPStatus.BAD_REQUEST) agent = manager.backup_agents[agent_id] @@ -58,6 +68,24 @@ async def get( headers = { CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } + + if not password: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager + ) + + async def _send_backup_no_password( + self, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: if agent_id in manager.local_backup_agents: local_agent = manager.local_backup_agents[agent_id] path = local_agent.get_backup_path(backup_id) @@ -70,6 +98,50 @@ async def get( await response.write(chunk) return response + async def _send_backup_with_password( + self, + hass: HomeAssistant, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + password: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: + reader: IO[bytes] + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + try: + reader = await hass.async_add_executor_job(open, path.as_posix(), "rb") + except FileNotFoundError: + return Response(status=HTTPStatus.NOT_FOUND) + else: + stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream)) + + worker_done_event = asyncio.Event() + + def on_done() -> None: + """Call by the worker thread when it's done.""" + hass.loop.call_soon_threadsafe(worker_done_event.set) + + stream = util.AsyncIteratorWriter(hass) + worker = threading.Thread( + target=util.decrypt_backup, args=[reader, stream, password, on_done] + ) + try: + worker.start() + response = StreamResponse(status=HTTPStatus.OK, headers=headers) + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response + finally: + reader.close() + await worker_done_event.wait() + class UploadBackupView(HomeAssistantView): """Generate backup view.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1910f8a55fb2fb..32979194980745 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -10,11 +10,11 @@ import hashlib import io import json -from pathlib import Path +from pathlib import Path, PurePath import shutil import tarfile import time -from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add @@ -31,6 +31,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util +from . import util as backup_util from .agent import ( BackupAgent, BackupAgentError, @@ -48,7 +49,13 @@ ) from .models import AgentBackup, BackupManagerError, Folder from .store import BackupStore -from .util import make_backup_dir, read_backup, validate_password +from .util import ( + AsyncIteratorReader, + make_backup_dir, + read_backup, + validate_password, + validate_password_stream, +) @dataclass(frozen=True, kw_only=True, slots=True) @@ -140,6 +147,7 @@ class RestoreBackupState(StrEnum): """Receive backup state enum.""" COMPLETED = "completed" + CORE_RESTART = "core_restart" FAILED = "failed" IN_PROGRESS = "in_progress" @@ -210,7 +218,7 @@ async def async_create_backup( include_database: bool, include_folders: list[Folder] | None, include_homeassistant: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: """Create a backup.""" @@ -231,6 +239,7 @@ async def async_restore_backup( backup_id: str, *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], password: str | None, restore_addons: list[str] | None, @@ -248,6 +257,14 @@ class BackupReaderWriterError(HomeAssistantError): class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + _message = "The password provided is incorrect." + + +class DecryptOnDowloadNotSupported(BackupManagerError): + """Raised when on-the-fly decryption is not supported.""" + + _message = "On-the-fly decryption is not supported for this backup." + class BackupManager: """Define the format that backup managers can have.""" @@ -430,17 +447,21 @@ async def _async_upload_backup( return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupReaderWriterError): # writer errors will affect all agents # no point in continuing raise BackupManagerError(str(result)) from result if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + LOGGER.error("Upload failed for %s: %s", agent_id, result) continue if isinstance(result, Exception): # trap bugs from agents - agent_errors[agent_ids[idx]] = result - LOGGER.error("Unexpected error: %s", result, exc_info=result) + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result @@ -752,7 +773,7 @@ async def _async_create_backup( backup_name = ( name - or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}" + or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) try: @@ -922,6 +943,7 @@ async def open_backup() -> AsyncIterator[bytes]: backup_id=backup_id, open_stream=open_backup, agent_id=agent_id, + on_progress=self.async_on_backup_event, password=password, restore_addons=restore_addons, restore_database=restore_database, @@ -986,6 +1008,41 @@ def _update_issue_after_agent_upload( translation_placeholders={"failed_agents": ", ".join(agent_errors)}, ) + async def async_can_decrypt_on_download( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + ) -> None: + """Check if we are able to decrypt the backup on download.""" + try: + agent = self.backup_agents[agent_id] + except KeyError as err: + raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err + if not await agent.async_get_backup(backup_id): + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + reader: IO[bytes] + if agent_id in self.local_backup_agents: + local_agent = self.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") + else: + backup_stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) + try: + validate_password_stream(reader, password) + except backup_util.IncorrectPassword as err: + raise IncorrectPasswordError from err + except backup_util.UnsupportedSecureTarVersion as err: + raise DecryptOnDowloadNotSupported from err + except backup_util.DecryptError as err: + raise BackupManagerError(str(err)) from err + finally: + reader.close() + class KnownBackups: """Track known backups.""" @@ -1076,7 +1133,7 @@ async def async_create_backup( include_database: bool, include_folders: list[Folder] | None, include_homeassistant: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: """Initiate generating a backup.""" @@ -1116,7 +1173,7 @@ async def _async_create_backup( date_str: str, extra_metadata: dict[str, bool | str], include_database: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> WrittenBackup: """Generate a backup.""" @@ -1230,6 +1287,17 @@ def _mkdir_and_generate_backup_contents( if not database_included: excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP + def is_excluded_by_filter(path: PurePath) -> bool: + """Filter to filter excludes.""" + + for exclude in excludes: + if not path.match(exclude): + continue + LOGGER.debug("Ignoring %s because of %s", path, exclude) + return True + + return False + outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) @@ -1248,7 +1316,7 @@ def _mkdir_and_generate_backup_contents( atomic_contents_add( tar_file=core_tar, origin_path=Path(self._hass.config.path()), - excludes=excludes, + file_filter=is_excluded_by_filter, arcname="data", ) return (tar_file_path, tar_file_path.stat().st_size) @@ -1313,6 +1381,7 @@ async def async_restore_backup( open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], password: str | None, restore_addons: list[str] | None, restore_database: bool, @@ -1357,7 +1426,7 @@ async def async_restore_backup( validate_password, path, password ) if not password_valid: - raise IncorrectPasswordError("The password provided is incorrect.") + raise IncorrectPasswordError def _write_restore_file() -> None: """Write the restore file.""" @@ -1375,6 +1444,9 @@ def _write_restore_file() -> None: ) await self._hass.async_add_executor_job(_write_restore_file) + on_progress( + RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index b399043e013682..ffaed260c88ade 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2024.11.0"] + "requirements": ["cronsim==2.6", "securetar==2025.1.3"] } diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index ddabead24f990d..205bdf80375520 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store @@ -16,6 +16,7 @@ STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 class StoredBackupData(TypedDict): @@ -25,14 +26,44 @@ class StoredBackupData(TypedDict): config: StoredBackupConfig +class _BackupStore(Store[StoredBackupData]): + """Class to help storing backup data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + STORAGE_VERSION, + STORAGE_KEY, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1: + if old_minor_version < 2: + # Version 1.2 adds configurable backup time + data["config"]["schedule"]["time"] = None + + if old_major_version > 1: + raise NotImplementedError + return data + + class BackupStore: """Store backup config.""" def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: - """Initialize the backup manager.""" + """Initialize the backup store.""" self._hass = hass self._manager = manager - self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _BackupStore(hass) async def load(self) -> StoredBackupData | None: """Load the store.""" diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 930625c52ca2d1..e5acf974012ef0 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,22 +3,51 @@ from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import AsyncIterator, Callable +import copy +from io import BytesIO +import json +from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -from typing import cast +from typing import IO, Self, cast import aiohttp -from securetar import SecureTarFile +from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import JsonObjectType, json_loads_object from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder +class DecryptError(HomeAssistantError): + """Error during decryption.""" + + _message = "Unexpected error during decryption." + + +class UnsupportedSecureTarVersion(DecryptError): + """Unsupported securetar version.""" + + _message = "Unsupported securetar version." + + +class IncorrectPassword(DecryptError): + """Invalid password or corrupted backup.""" + + _message = "Invalid password or corrupted backup." + + +class BackupEmpty(DecryptError): + """No tar files found in the backup.""" + + _message = "No tar files found in the backup." + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -106,6 +135,159 @@ def validate_password(path: Path, password: str | None) -> bool: return False +class AsyncIteratorReader: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._stream = stream + self._buffer: bytes | None = None + self._pos: int = 0 + + async def _next(self) -> bytes | None: + """Get the next chunk from the iterator.""" + return await anext(self._stream, None) + + def read(self, n: int = -1, /) -> bytes: + """Read data from the iterator.""" + result = bytearray() + while n < 0 or len(result) < n: + if not self._buffer: + self._buffer = asyncio.run_coroutine_threadsafe( + self._next(), self._hass.loop + ).result() + self._pos = 0 + if not self._buffer: + # The stream is exhausted + break + chunk = self._buffer[self._pos : self._pos + n] + result.extend(chunk) + n -= len(chunk) + self._pos += len(chunk) + if self._pos == len(self._buffer): + self._buffer = None + return bytes(result) + + def close(self) -> None: + """Close the iterator.""" + + +class AsyncIteratorWriter: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) + + def __aiter__(self) -> Self: + """Return the iterator.""" + return self + + async def __anext__(self) -> bytes: + """Get the next chunk from the iterator.""" + if data := await self._queue.get(): + return data + raise StopAsyncIteration + + def write(self, s: bytes, /) -> int: + """Write data to the iterator.""" + asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + return len(s) + + +def validate_password_stream( + input_stream: IO[bytes], + password: str | None, +) -> None: + """Decrypt a backup.""" + with ( + tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar, + ): + for obj in input_tar: + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + if istf.securetar_header.plaintext_size is None: + raise UnsupportedSecureTarVersion + try: + decrypted.read(1) # Read a single byte to trigger the decryption + except SecureTarReadError as err: + raise IncorrectPassword from err + return + raise BackupEmpty + + +def decrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[], None], +) -> None: + """Decrypt a backup.""" + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + finally: + output_stream.write(b"") # Write an empty chunk to signal the end of the stream + on_done() + + +def _decrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, +) -> None: + """Decrypt a backup.""" + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is decrypted + if not (reader := input_tar.extractfile(obj)): + raise DecryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = False + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + if (plaintext_size := istf.securetar_header.plaintext_size) is None: + raise UnsupportedSecureTarVersion + decrypted_obj = copy.deepcopy(obj) + decrypted_obj.size = plaintext_size + output_tar.addfile(decrypted_obj, decrypted) + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 0139b7fdb77952..235d53952c1297 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,10 +6,15 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from .config import ScheduleState from .const import DATA_MANAGER, LOGGER -from .manager import IncorrectPasswordError, ManagerStateEvent +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) from .models import Folder @@ -24,6 +29,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_can_decrypt_on_download) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) @@ -54,6 +60,7 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, }, ) @@ -147,6 +154,38 @@ async def handle_restore( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/can_decrypt_on_download", + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def handle_can_decrypt_on_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Check if the supplied password is correct.""" + try: + await hass.data[DATA_MANAGER].async_can_decrypt_on_download( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + ) + except IncorrectPasswordError: + connection.send_error(msg["id"], "password_incorrect", "Incorrect password") + except DecryptOnDowloadNotSupported: + connection.send_error( + msg["id"], "decrypt_not_supported", "Decrypt on download not supported" + ) + else: + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -284,7 +323,10 @@ async def handle_config_info( connection.send_result( msg["id"], { - "config": manager.config.data.to_dict(), + "config": manager.config.data.to_dict() + | { + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup + }, }, ) @@ -314,7 +356,12 @@ async def handle_config_info( vol.Optional("days"): vol.Any(int, None), }, ), - vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("schedule"): vol.Schema( + { + vol.Optional("state"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("time"): vol.Any(cv.time, None), + } + ), } ) @websocket_api.async_response diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 0d56699e1cee79..4dbb59165fa27e 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -10,9 +10,9 @@ from aiobafi6.discovery import PORT import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -44,7 +44,7 @@ def __init__(self) -> None: self.discovery: BAFDiscovery | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.ip_address.version == 6: diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index a7d75bfbdf5a8c..a541d044a21126 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -20,7 +20,7 @@ def __init__(self, client: SpaClient, key: str) -> None: """Initialize the control.""" mac = client.mac_address model = client.model - self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}' + self._attr_unique_id = f"{model}-{key}-{mac.replace(':', '')[-6:]}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index e1c1c7ab538635..e776b63b945e60 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -10,10 +10,10 @@ from mozart_api.mozart_client import MozartClient import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context from .const import ( diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6d203c344f222d..74e3db34b682f2 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -131,7 +131,10 @@ class NumericConfig(NamedTuple): for i, tup in enumerate(intervals): if len(intervals) > i + 1 and tup.below > intervals[i + 1].above: raise vol.Invalid( - f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}." + "Ranges for bayesian numeric state entities must not overlap, " + f"but {ent_id} has overlapping ranges, above:{tup.above}, " + f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, " + f"below:{intervals[i + 1].below}." ) return configs @@ -206,7 +209,10 @@ async def async_setup_platform( broken_observations: list[dict[str, Any]] = [] for observation in observations: if CONF_P_GIVEN_F not in observation: - text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}" + text = ( + f"{name}/{observation.get(CONF_ENTITY_ID, '')}" + f"{observation.get(CONF_VALUE_TEMPLATE, '')}" + ) raise_no_prob_given_false(hass, text) _LOGGER.error("Missing prob_given_false YAML entry for %s", text) broken_observations.append(observation) diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 2221e35a81f81d..523b5af793fe2b 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -15,10 +15,10 @@ from blebox_uniapi.session import ApiHost import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import get_maybe_authenticated_session from .const import ( @@ -84,7 +84,7 @@ def handle_step_exception( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" hass = self.hass diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 3ba6349b714f60..0154c794c33162 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -5,7 +5,7 @@ "data": { "api_token": "[%key:common::config_flow::data::api_token%]" }, - "description": "Enter your Blue Current api token", + "description": "Enter your Blue Current API token", "title": "Authentication" } }, @@ -19,7 +19,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "Wrong account: Please authenticate with the api key for {email}." + "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, "entity": { diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index c10da532324397..544f9554b9fbc6 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -136,7 +136,7 @@ def _extract_blueprint_from_community_topic( ) return ImportedBlueprint( - f'{post["username"]}/{topic["slug"]}', block_content, blueprint + f"{post['username']}/{topic['slug']}", block_content, blueprint ) @@ -173,8 +173,7 @@ async def fetch_blueprint_from_github_url( parsed_import_url = yarl.URL(import_url) suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}" - if suggested_filename.endswith(".yaml"): - suggested_filename = suggested_filename[:-5] + suggested_filename = suggested_filename.removesuffix(".yaml") return ImportedBlueprint(suggested_filename, raw_yaml, blueprint) diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index b3facc0b8ac7e8..6cf1957f79914d 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -14,10 +14,13 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .coordinator import BluesoundCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.MEDIA_PLAYER, +] @dataclass @@ -26,6 +29,7 @@ class BluesoundRuntimeData: player: Player sync_status: SyncStatus + coordinator: BluesoundCoordinator type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] @@ -33,9 +37,6 @@ class BluesoundRuntimeData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = [] - return True @@ -46,13 +47,16 @@ async def async_setup_entry( host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] session = async_get_clientsession(hass) - async with Player(host, port, session=session, default_timeout=10) as player: - try: - sync_status = await player.sync_status(timeout=1) - except PlayerUnreachableError as ex: - raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex + player = Player(host, port, session=session, default_timeout=10) + try: + sync_status = await player.sync_status(timeout=1) + except PlayerUnreachableError as ex: + raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex + + coordinator = BluesoundCoordinator(hass, player, sync_status) + await coordinator.async_config_entry_first_refresh() - config_entry.runtime_data = BluesoundRuntimeData(player, sync_status) + config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 050b3ee4eacfbf..2f002b70e1d37a 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -7,10 +7,10 @@ from pyblu.errors import PlayerUnreachableError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .media_player import DEFAULT_PORT @@ -71,29 +71,8 @@ async def async_step_user( ), ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import bluesound config entry from configuration.yaml.""" - session = async_get_clientsession(self.hass) - async with Player( - import_data[CONF_HOST], import_data[CONF_PORT], session=session - ) as player: - try: - sync_status = await player.sync_status(timeout=1) - except PlayerUnreachableError: - return self.async_abort(reason="cannot_connect") - - await self.async_set_unique_id( - format_unique_id(sync_status.mac, import_data[CONF_PORT]) - ) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=sync_status.name, - data=import_data, - ) - async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if discovery_info.port is not None: diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py new file mode 100644 index 00000000000000..e62f3ef96cf672 --- /dev/null +++ b/homeassistant/components/bluesound/coordinator.py @@ -0,0 +1,160 @@ +"""Define a base coordinator for Bluesound entities.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import contextlib +from dataclasses import dataclass, replace +from datetime import timedelta +import logging + +from pyblu import Input, Player, Preset, Status, SyncStatus +from pyblu.errors import PlayerUnreachableError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) +PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) + + +@dataclass +class BluesoundData: + """Define a class to hold Bluesound data.""" + + sync_status: SyncStatus + status: Status + presets: list[Preset] + inputs: list[Input] + + +def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: + """Cancel a task.""" + + async def _cancel_task() -> None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + return _cancel_task + + +class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): + """Define an object to hold Bluesound data.""" + + def __init__( + self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + ) -> None: + """Initialize.""" + self.player = player + self._inital_sync_status = sync_status + + super().__init__( + hass, + logger=_LOGGER, + name=sync_status.name, + ) + + async def _async_setup(self) -> None: + assert self.config_entry is not None + + preset = await self.player.presets() + inputs = await self.player.inputs() + status = await self.player.status() + + self.async_set_updated_data( + BluesoundData( + sync_status=self._inital_sync_status, + status=status, + presets=preset, + inputs=inputs, + ) + ) + + status_loop_task = self.hass.async_create_background_task( + self._poll_status_loop(), + name=f"bluesound.poll_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(status_loop_task)) + + sync_status_loop_task = self.hass.async_create_background_task( + self._poll_sync_status_loop(), + name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(sync_status_loop_task)) + + presets_and_inputs_loop_task = self.hass.async_create_background_task( + self._poll_presets_and_inputs_loop(), + name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task)) + + async def _async_update_data(self) -> BluesoundData: + return self.data + + async def _poll_presets_and_inputs_loop(self) -> None: + while True: + await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds()) + try: + preset = await self.player.presets() + inputs = await self.player.inputs() + self.async_set_updated_data( + replace( + self.data, + presets=preset, + inputs=inputs, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + + async def _poll_status_loop(self) -> None: + """Loop which polls the status of the player.""" + while True: + try: + status = await self.player.status( + etag=self.data.status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + status=status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + + async def _poll_sync_status_loop(self) -> None: + """Loop which polls the sync status of the player.""" + while True: + try: + sync_status = await self.player.sync_status( + etag=self.data.sync_status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + sync_status=sync_status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + raise + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 4882d543617d1c..12e2f5379359f8 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,20 +2,16 @@ from __future__ import annotations -import asyncio -from asyncio import CancelledError, Task -from contextlib import suppress +from asyncio import Task from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING, Any from pyblu import Input, Player, Preset, Status, SyncStatus -from pyblu.errors import PlayerUnreachableError import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -23,16 +19,10 @@ MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -43,10 +33,11 @@ async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util -from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE +from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN +from .coordinator import BluesoundCoordinator from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id if TYPE_CHECKING: @@ -64,71 +55,8 @@ SERVICE_SET_TIMER = "set_sleep_timer" SERVICE_UNJOIN = "unjoin" -NODE_OFFLINE_CHECK_TIMEOUT = 180 -NODE_RETRY_INITIATION = timedelta(minutes=3) - -SYNC_STATUS_INTERVAL = timedelta(minutes=5) - POLL_TIMEOUT = 120 -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOSTS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } - ], - ) - } -) - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Import config entry from configuration.yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result["type"] == FlowResultType.ABORT - and result["reason"] == "cannot_connect" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - async def async_setup_entry( hass: HomeAssistant, @@ -137,10 +65,10 @@ async def async_setup_entry( ) -> None: """Set up the Bluesound entry.""" bluesound_player = BluesoundPlayer( + config_entry.runtime_data.coordinator, config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], config_entry.runtime_data.player, - config_entry.runtime_data.sync_status, ) platform = entity_platform.async_get_current_platform() @@ -155,27 +83,10 @@ async def async_setup_entry( ) platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") - hass.data[DATA_BLUESOUND].append(bluesound_player) async_add_entities([bluesound_player], update_before_add=True) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None, -) -> None: - """Trigger import flows.""" - hosts = config.get(CONF_HOSTS, []) - for host in hosts: - import_data = { - CONF_HOST: host[CONF_HOST], - CONF_PORT: host.get(CONF_PORT, 11000), - } - hass.async_create_task(_async_import(hass, import_data)) - - -class BluesoundPlayer(MediaPlayerEntity): +class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC @@ -184,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity): def __init__( self, + coordinator: BluesoundCoordinator, host: str, port: int, player: Player, - sync_status: SyncStatus, ) -> None: """Initialize the media player.""" + super().__init__(coordinator) + sync_status = coordinator.data.sync_status + self.host = host self.port = port self._poll_status_loop_task: Task[None] | None = None @@ -197,15 +111,14 @@ def __init__( self._id = sync_status.id self._last_status_update: datetime | None = None self._sync_status = sync_status - self._status: Status | None = None - self._inputs: list[Input] = [] - self._presets: list[Preset] = [] + self._status: Status = coordinator.data.status + self._inputs: list[Input] = coordinator.data.inputs + self._presets: list[Preset] = coordinator.data.presets self._group_name: str | None = None self._group_list: list[str] = [] self._bluesound_device_name = sync_status.name self._player = player - self._is_leader = False - self._leader: BluesoundPlayer | None = None + self._last_status_update = dt_util.utcnow() self._attr_unique_id = format_unique_id(sync_status.mac, port) # there should always be one player with the default port per mac @@ -228,52 +141,10 @@ def __init__( via_device=(DOMAIN, format_mac(sync_status.mac)), ) - async def _poll_status_loop(self) -> None: - """Loop which polls the status of the player.""" - while True: - try: - await self.async_update_status() - except PlayerUnreachableError: - _LOGGER.error( - "Node %s:%s is offline, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - _LOGGER.debug( - "Stopping the polling of node %s:%s", self.host, self.port - ) - return - except: # noqa: E722 - this loop should never stop - _LOGGER.exception( - "Unexpected error for %s:%s, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - - async def _poll_sync_status_loop(self) -> None: - """Loop which polls the sync status of the player.""" - while True: - try: - await self.update_sync_status() - except PlayerUnreachableError: - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - raise - except: # noqa: E722 - all errors must be caught for this loop - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - async def async_added_to_hass(self) -> None: """Start the polling task.""" await super().async_added_to_hass() - self._poll_status_loop_task = self.hass.async_create_background_task( - self._poll_status_loop(), - name=f"bluesound.poll_status_loop_{self.host}:{self.port}", - ) - self._poll_sync_status_loop_task = self.hass.async_create_background_task( - self._poll_sync_status_loop(), - name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}", - ) - assert self._sync_status.id is not None self.async_on_remove( async_dispatcher_connect( @@ -294,105 +165,24 @@ async def async_will_remove_from_hass(self) -> None: """Stop the polling task.""" await super().async_will_remove_from_hass() - assert self._poll_status_loop_task is not None - if self._poll_status_loop_task.cancel(): - # the sleeps in _poll_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_status_loop_task + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._sync_status = self.coordinator.data.sync_status + self._status = self.coordinator.data.status + self._inputs = self.coordinator.data.inputs + self._presets = self.coordinator.data.presets - assert self._poll_sync_status_loop_task is not None - if self._poll_sync_status_loop_task.cancel(): - # the sleeps in _poll_sync_status_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_sync_status_loop_task - - self.hass.data[DATA_BLUESOUND].remove(self) - - async def async_update(self) -> None: - """Update internal status of the entity.""" - if not self.available: - return - - with suppress(PlayerUnreachableError): - await self.async_update_presets() - await self.async_update_captures() - - async def async_update_status(self) -> None: - """Use the poll session to always get the status of the player.""" - etag = None - if self._status is not None: - etag = self._status.etag - - try: - status = await self._player.status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._attr_available = True - self._last_status_update = dt_util.utcnow() - self._status = status - - self.async_write_ha_state() - except PlayerUnreachableError: - self._attr_available = False - self._last_status_update = None - self._status = None - self.async_write_ha_state() - _LOGGER.error( - "Client connection error, marking %s as offline", - self._bluesound_device_name, - ) - raise - - async def update_sync_status(self) -> None: - """Update the internal status.""" - etag = None - if self._sync_status: - etag = self._sync_status.etag - sync_status = await self._player.sync_status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._sync_status = sync_status + self._last_status_update = dt_util.utcnow() self._group_list = self.rebuild_bluesound_group() - if sync_status.leader is not None: - self._is_leader = False - leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}" - leader_device = [ - device - for device in self.hass.data[DATA_BLUESOUND] - if device.id == leader_id - ] - - if leader_device and leader_id != self.id: - self._leader = leader_device[0] - else: - self._leader = None - _LOGGER.error("Leader not found %s", leader_id) - else: - if self._leader is not None: - self._leader = None - followers = self._sync_status.followers - self._is_leader = followers is not None - self.async_write_ha_state() - async def async_update_captures(self) -> None: - """Update Capture sources.""" - inputs = await self._player.inputs() - self._inputs = inputs - - async def async_update_presets(self) -> None: - """Update Presets.""" - presets = await self._player.presets() - self._presets = presets - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" - if self._status is None: + if self.available is False: return MediaPlayerState.OFF if self.is_grouped and not self.is_leader: @@ -409,7 +199,7 @@ def state(self) -> MediaPlayerState: @property def media_title(self) -> str | None: """Title of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.name @@ -417,7 +207,7 @@ def media_title(self) -> str | None: @property def media_artist(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None: + if self.available is False: return None if self.is_grouped and not self.is_leader: @@ -428,7 +218,7 @@ def media_artist(self) -> str | None: @property def media_album_name(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.album @@ -436,7 +226,7 @@ def media_album_name(self) -> str | None: @property def media_image_url(self) -> str | None: """Image url of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None url = self._status.image @@ -451,7 +241,7 @@ def media_image_url(self) -> str | None: @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None mediastate = self.state @@ -470,7 +260,7 @@ def media_position(self) -> int | None: @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None duration = self._status.total_seconds @@ -487,16 +277,11 @@ def media_position_updated_at(self) -> datetime | None: @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - volume = None + volume = self._status.volume - if self._status is not None: - volume = self._status.volume if self.is_grouped: volume = self._sync_status.volume - if volume is None: - return None - return volume / 100 @property @@ -529,7 +314,7 @@ def sync_status(self) -> SyncStatus: @property def source_list(self) -> list[str] | None: """List of available input sources.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None sources = [x.text for x in self._inputs] @@ -540,7 +325,7 @@ def source_list(self) -> list[str] | None: @property def source(self) -> str | None: """Name of the current input source.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None if self._status.input_id is not None: @@ -557,7 +342,7 @@ def source(self) -> str | None: @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag of media commands that are supported.""" - if self._status is None: + if self.available is False: return MediaPlayerEntityFeature(0) if self.is_grouped and not self.is_leader: @@ -659,16 +444,21 @@ def rebuild_bluesound_group(self) -> list[str]: if self.sync_status.leader is None and self.sync_status.followers is None: return [] - player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND] + config_entries: list[BluesoundConfigEntry] = ( + self.hass.config_entries.async_entries(DOMAIN) + ) + sync_status_list = [ + x.runtime_data.coordinator.data.sync_status for x in config_entries + ] leader_sync_status: SyncStatus | None = None if self.sync_status.leader is None: leader_sync_status = self.sync_status else: required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}" - for x in player_entities: - if x.sync_status.id == required_id: - leader_sync_status = x.sync_status + for sync_status in sync_status_list: + if sync_status.id == required_id: + leader_sync_status = sync_status break if leader_sync_status is None or leader_sync_status.followers is None: @@ -676,9 +466,9 @@ def rebuild_bluesound_group(self) -> list[str]: follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers] follower_names = [ - x.sync_status.name - for x in player_entities - if x.sync_status.id in follower_ids + sync_status.name + for sync_status in sync_status_list + if sync_status.id in follower_ids ] follower_names.insert(0, leader_sync_status.name) return follower_names diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 645adfdcd2d50b..5edec1ccc238f9 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -22,6 +22,7 @@ adapter_model, adapter_unique_name, get_adapters, + get_manufacturer_from_mac, ) from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( @@ -51,7 +52,7 @@ from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import passive_update_processor +from . import passive_update_processor, websocket_api from .api import ( _get_manager, async_address_present, @@ -66,6 +67,7 @@ async_rediscover_address, async_register_callback, async_register_scanner, + async_remove_scanner, async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, @@ -77,6 +79,9 @@ CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, @@ -92,9 +97,24 @@ from homeassistant.helpers.typing import ConfigType __all__ = [ + "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", + "SOURCE_LOCAL", + "BaseHaRemoteScanner", + "BaseHaScanner", + "BluetoothCallback", + "BluetoothCallbackMatcher", + "BluetoothChange", + "BluetoothScannerDevice", + "BluetoothScanningMode", + "BluetoothServiceInfo", + "BluetoothServiceInfoBleak", + "HaBluetoothConnector", + "HomeAssistantRemoteScanner", "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_advertisement_callback", "async_get_fallback_availability_interval", "async_get_learned_advertising_interval", "async_get_scanner", @@ -103,26 +123,12 @@ "async_rediscover_address", "async_register_callback", "async_register_scanner", - "async_set_fallback_availability_interval", - "async_track_unavailable", + "async_remove_scanner", "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", - "async_get_advertisement_callback", - "BaseHaScanner", - "HomeAssistantRemoteScanner", - "BluetoothCallbackMatcher", - "BluetoothChange", - "BluetoothServiceInfo", - "BluetoothServiceInfoBleak", - "BluetoothScanningMode", - "BluetoothCallback", - "BluetoothScannerDevice", - "HaBluetoothConnector", - "BaseHaRemoteScanner", - "SOURCE_LOCAL", - "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", - "MONOTONIC_TIME", + "async_set_fallback_availability_interval", + "async_track_unavailable", ] _LOGGER = logging.getLogger(__name__) @@ -232,6 +238,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: set_manager(manager) await storage_setup_task await manager.async_setup() + websocket_api.async_setup(hass) hass.async_create_background_task( _async_start_adapter_discovery(hass, manager, bluetooth_adapters), @@ -312,6 +319,38 @@ async def async_update_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" + if source_entry_id := entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID): + if not (source_entry := hass.config_entries.async_get_entry(source_entry_id)): + # Cleanup the orphaned entry using a call_soon to ensure + # we can return before the entry is removed + hass.loop.call_soon( + hass_callback( + lambda: hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id), + "remove orphaned bluetooth entry {entry.entry_id}", + ) + ) + ) + address = entry.unique_id + assert address is not None + assert source_entry is not None + source_domain = entry.data[CONF_SOURCE_DOMAIN] + if mac_manufacturer := await get_manufacturer_from_mac(address): + manufacturer = f"{mac_manufacturer} ({source_domain})" + else: + manufacturer = source_domain + details = AdapterDetails( + address=address, + product=entry.data.get(CONF_SOURCE_MODEL), + manufacturer=manufacturer, + ) + await async_update_device( + hass, + entry, + source_entry.title, + details, + ) + return True manager = _get_manager(hass) address = entry.unique_id assert address is not None diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 7c3d1bc36205f7..03c278d6b0d9b4 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -132,7 +132,7 @@ async def _async_poll(self) -> None: ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index e7b65067070a37..8a23de682e6360 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -127,7 +127,7 @@ async def _async_poll(self) -> None: ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 505651edafddca..9db570c4cbaffd 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -178,9 +178,20 @@ def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner(scanner, connection_slots) + return _get_manager(hass).async_register_hass_scanner( + scanner, connection_slots, source_domain, source_model, source_config_entry_id + ) + + +@hass_callback +def async_remove_scanner(hass: HomeAssistant, source: str) -> None: + """Permanently remove a BleakScanner by source address.""" + return _get_manager(hass).async_remove_scanner(source) @hass_callback diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 37eefd2f265bf1..5bfe5e7089ccd3 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -18,7 +18,12 @@ import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import callback from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -26,7 +31,16 @@ ) from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN +from .const import ( + CONF_ADAPTER, + CONF_DETAILS, + CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .util import adapter_title OPTIONS_SCHEMA = vol.Schema( @@ -63,6 +77,8 @@ async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" + if discovery_info and CONF_SOURCE in discovery_info: + return await self.async_step_external_scanner(discovery_info) self._adapter = cast(str, discovery_info[CONF_ADAPTER]) self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS]) await self.async_set_unique_id(self._details[ADAPTER_ADDRESS]) @@ -167,6 +183,24 @@ async def async_step_multiple_adapters( ), ) + async def async_step_external_scanner( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by an external scanner.""" + source = user_input[CONF_SOURCE] + await self.async_set_unique_id(source) + data = { + CONF_SOURCE: source, + CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], + CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], + CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + } + self._abort_if_unique_id_configured(updates=data) + manager = get_manager() + scanner = manager.async_scanner_by_source(source) + assert scanner is not None + return self.async_create_entry(title=scanner.name, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -177,8 +211,10 @@ async def async_step_user( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler: + ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: """Get the options flow for this handler.""" + if CONF_SOURCE in config_entry.data: + return RemoteAdapterOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -186,3 +222,13 @@ def async_get_options_flow( def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return bool((manager := get_manager()) and manager.supports_passive_scan) + + +class RemoteAdapterOptionsFlowHandler(OptionsFlow): + """Handle a option flow for remote adapters.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="remote_adapters_not_supported") diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index a3238befbb80a4..d4b187d460544d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -18,6 +18,12 @@ CONF_PASSIVE = "passive" +CONF_SOURCE: Final = "source" +CONF_SOURCE_DOMAIN: Final = "source_domain" +CONF_SOURCE_MODEL: Final = "source_model" +CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" + + SOURCE_LOCAL: Final = "local" DATA_MANAGER: Final = "bluetooth_manager" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e192423484cabe..09be8f960e9491 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -22,7 +22,13 @@ from homeassistant.helpers import discovery_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN +from .const import ( + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .match import ( ADDRESS, CALLBACK, @@ -44,11 +50,11 @@ class HomeAssistantBluetoothManager(BluetoothManager): """Manage Bluetooth for Home Assistant.""" __slots__ = ( - "hass", - "storage", - "_integration_matcher", "_callback_index", "_cancel_logging_listener", + "_integration_matcher", + "hass", + "storage", ) def __init__( @@ -240,6 +246,39 @@ def _async_unregister_scanner( unregister() self._async_save_scanner_history(scanner) + @hass_callback + def async_register_hass_scanner( + self, + scanner: BaseHaScanner, + connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, + ) -> CALLBACK_TYPE: + """Register a scanner.""" + cancel = self.async_register_scanner(scanner, connection_slots) + if ( + isinstance(scanner, BaseHaRemoteScanner) + and source_domain + and source_config_entry_id + and not self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, scanner.source + ) + ): + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: source_domain, + CONF_SOURCE_MODEL: source_model, + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, + }, + ) + ) + return cancel + def async_register_scanner( self, scanner: BaseHaScanner, @@ -253,6 +292,18 @@ def async_register_scanner( unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) + @hass_callback + def async_remove_scanner(self, source: str) -> None: + """Remove a scanner.""" + self.storage.async_remove_advertisement_history(source) + if entry := self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source + ): + self.hass.async_create_task( + self.hass.config_entries.async_remove(entry.entry_id), + f"Removing {source} Bluetooth config entry", + ) + @hass_callback def _handle_config_entry_removed( self, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e25c077b57fa43..b5aa6cfa12f5c8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -10,16 +10,17 @@ "btsocket", "bleak_retry_connector", "bluetooth_adapters", - "bluetooth_auto_recovery" + "bluetooth_auto_recovery", + "habluetooth" ], "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.6.0", - "bluetooth-adapters==0.20.2", + "bleak-retry-connector==3.7.0", + "bluetooth-adapters==0.21.0", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.20.0", - "dbus-fast==2.24.3", - "habluetooth==3.6.0" + "bluetooth-data-tools==1.22.0", + "dbus-fast==2.30.2", + "habluetooth==3.9.2" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index ee62420b6921d9..6307d3ca93b2f9 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -92,7 +92,7 @@ def seen_all_fields( class IntegrationMatcher: """Integration matcher for the bluetooth integration.""" - __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index") + __slots__ = ("_index", "_integration_matchers", "_matched", "_matched_connectable") def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: """Initialize the matcher.""" @@ -164,12 +164,12 @@ class BluetoothMatcherIndexBase[ __slots__ = ( "local_name", - "service_uuid", - "service_data_uuid", "manufacturer_id", - "service_uuid_set", - "service_data_uuid_set", "manufacturer_id_set", + "service_data_uuid", + "service_data_uuid_set", + "service_uuid", + "service_uuid_set", ) def __init__(self) -> None: diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index be232f87b24a3f..ccff85e5027546 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -4,8 +4,6 @@ from typing import TYPE_CHECKING, Any -from typing_extensions import TypeVar - from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, @@ -20,12 +18,6 @@ from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak -_PassiveBluetoothDataUpdateCoordinatorT = TypeVar( - "_PassiveBluetoothDataUpdateCoordinatorT", - bound="PassiveBluetoothDataUpdateCoordinator", - default="PassiveBluetoothDataUpdateCoordinator", -) - class PassiveBluetoothDataUpdateCoordinator( BasePassiveBluetoothCoordinator, BaseDataUpdateCoordinatorProtocol @@ -98,7 +90,9 @@ def _async_handle_bluetooth_event( self.async_update_listeners() -class PassiveBluetoothCoordinatorEntity( # pylint: disable=hass-enforce-class-module +class PassiveBluetoothCoordinatorEntity[ + _PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator +]( # pylint: disable=hass-enforce-class-module BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT] ): """A class for entities using DataUpdateCoordinator.""" diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 6b4c7695fd21d5..369db4a7760d3c 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -38,6 +38,12 @@ def scanners(self) -> list[str]: """Get all scanners.""" return list(self._data.keys()) + @callback + def async_remove_advertisement_history(self, scanner: str) -> None: + """Remove discovered devices by scanner.""" + if self._data.pop(scanner, None): + self._store.async_delay_save(self._async_get_data, SCANNER_SAVE_DELAY) + @callback def async_get_advertisement_history( self, scanner: str diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index c28bd3cc65e7dc..1b8231c66ca195 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -33,6 +33,9 @@ "passive": "Passive scanning" } } + }, + "abort": { + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." } } } diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py new file mode 100644 index 00000000000000..45445a7a00f343 --- /dev/null +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -0,0 +1,150 @@ +"""The bluetooth integration websocket apis.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from functools import lru_cache, partial +import time +from typing import Any + +from habluetooth import BluetoothScanningMode +from home_assistant_bluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .api import _get_manager, async_register_callback +from .match import BluetoothCallbackMatcher +from .models import BluetoothChange + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the bluetooth websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_advertisements) + + +@lru_cache(maxsize=1024) +def serialize_service_info( + service_info: BluetoothServiceInfoBleak, time_diff: float +) -> dict[str, Any]: + """Serialize a BluetoothServiceInfoBleak object.""" + return { + "name": service_info.name, + "address": service_info.address, + "rssi": service_info.rssi, + "manufacturer_data": { + str(manufacturer_id): manufacturer_data.hex() + for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items() + }, + "service_data": { + service_uuid: service_data.hex() + for service_uuid, service_data in service_info.service_data.items() + }, + "service_uuids": service_info.service_uuids, + "source": service_info.source, + "connectable": service_info.connectable, + "time": service_info.time + time_diff, + "tx_power": service_info.tx_power, + } + + +class _AdvertisementSubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + match_dict: BluetoothCallbackMatcher, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.match_dict = match_dict + self.pending_service_infos: list[BluetoothServiceInfoBleak] = [] + self.ws_msg_id = ws_msg_id + self.connection = connection + self.pending = True + # Keep time_diff precise to 2 decimal places + # so the cached serialization can be reused, + # however we still want to calculate it each + # subscription in case the system clock is wrong + # and gets corrected. + self.time_diff = round(time.time() - time.monotonic(), 2) + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + @callback + def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + cancel_adv_callback = async_register_callback( + self.hass, + self._async_on_advertisement, + self.match_dict, + BluetoothScanningMode.PASSIVE, + ) + cancel_disappeared_callback = _get_manager( + self.hass + ).async_register_disappeared_callback(self._async_removed) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, (cancel_adv_callback, cancel_disappeared_callback) + ) + self.pending = False + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + self._async_added(self.pending_service_infos) + self.pending_service_infos.clear() + + def _async_event_message(self, message: dict[str, Any]) -> None: + self.connection.send_message( + json_bytes(websocket_api.event_message(self.ws_msg_id, message)) + ) + + def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + self._async_event_message( + { + "add": [ + serialize_service_info(service_info, self.time_diff) + for service_info in service_infos + ] + } + ) + + def _async_removed(self, address: str) -> None: + self._async_event_message({"remove": [{"address": address}]}) + + @callback + def _async_on_advertisement( + self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange + ) -> None: + """Handle the callback.""" + if self.pending: + self.pending_service_infos.append(service_info) + return + self._async_added((service_info,)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_advertisements", + } +) +@websocket_api.async_response +async def ws_subscribe_advertisements( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + _AdvertisementSubscription( + hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) + ).async_start() diff --git a/homeassistant/components/bmw_connected_drive/quality_scale.yaml b/homeassistant/components/bmw_connected_drive/quality_scale.yaml new file mode 100644 index 00000000000000..bc3bd51766275a --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/quality_scale.yaml @@ -0,0 +1,107 @@ +# + in comment indicates requirement for quality scale +# - in comment indicates issue to be fixed, not impacting quality scale +rules: + # Bronze + action-setup: + status: exempt + comment: | + Does not have custom services + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + - 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update() + config-flow-test-coverage: + status: todo + comment: | + - test_show_form doesn't really add anything + - Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports + + Ensure that configs flows end in CREATE_ENTRY or ABORT + - Parameterize test_authentication_error, test_api_error and test_connection_error + + test_full_user_flow_implementation doesn't assert unique id of created entry + + test that aborts when a mocked config entry already exists + + don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change) + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Does not have custom services + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration doesn't have any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + Does not have custom services + 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: done + test-coverage: + status: done + comment: | + - Use constants in tests where possible + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This integration doesn't use discovery. + discovery: + status: exempt + comment: This integration doesn't use discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: > + To be discussed. + We cannot regularly get new devices/vehicles due to API quota limitations. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + Other than reauthentication, this integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: > + To be discussed. + We cannot regularly check for stale devices/vehicles due to API quota limitations. + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: > + To be discussed. + The library requires a custom client for API authentication, with custom auth lifecycle and user agents. + strict-typing: done diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index a12d305725883c..38abd63186a79c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -11,12 +11,12 @@ from bond_async import Bond import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import BondHub @@ -97,7 +97,7 @@ async def _async_try_automatic_configure(self) -> None: self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" name: str = discovery_info.name diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 81f96b1772c768..2ae1df5fd684ce 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -115,11 +115,8 @@ async def async_update(self) -> None: def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" self._async_schedule_bpup_alive_or_poll() - if ( - self.hass.is_stopping - or self._bpup_subs.alive - and self._initialized - and self.available + if self.hass.is_stopping or ( + self._bpup_subs.alive and self._initialized and self.available ): return if self._update_lock.locked(): diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 58601152da53a3..c234000674d722 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_HOSTNAME, @@ -217,7 +218,7 @@ async def async_step_credentials( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if not discovery_info.name.startswith("Bosch SHC"): diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index db5c72d7932cc9..5d775b98180bf3 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -10,11 +10,16 @@ from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSupported import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.util.network import is_host_valid from .const import ( @@ -202,14 +207,14 @@ async def async_step_psk( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) @@ -221,8 +226,8 @@ async def async_step_ssdp( if "videoScreen" not in service_types: return self.async_abort(reason="not_bravia_device") - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self.context["title_placeholders"] = { CONF_NAME: f"{model_name} ({friendly_name})", diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 80b7a843cc09db..0ee8e3b3155099 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -4,20 +4,13 @@ import logging -from bring_api import ( - Bring, - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import Bring from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] @@ -30,30 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" - email = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - session = async_get_clientsession(hass) - bring = Bring(session, email, password) - - try: - await bring.login() - except BringRequestException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_request_exception", - ) from e - except BringParseException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_parse_exception", - ) from e - except BringAuthException as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_EMAIL: email}, - ) from e + bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) coordinator = BringDataUpdateCoordinator(hass, bring) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 7678213f117cef..d02237e84ebf93 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -51,7 +51,7 @@ async def _async_update_data(self) -> dict[str, BringData]: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - except BringAuthException as e: + except BringAuthException: # try to recover by refreshing access token, otherwise # initiate reauth flow try: @@ -64,12 +64,12 @@ async def _async_update_data(self) -> dict[str, BringData]: translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from exc - raise UpdateFailed( - "Authentication failed but re-authentication was successful, trying again later" - ) from e + return self.data list_dict: dict[str, BringData] = {} for lst in lists_response["lists"]: + if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + continue try: items = await self.bring.get_list(lst["listUuid"]) except BringRequestException as e: @@ -86,13 +86,22 @@ async def _async_update_data(self) -> dict[str, BringData]: async def _async_setup(self) -> None: """Set up coordinator.""" - await self.async_refresh_user_settings() - - async def async_refresh_user_settings(self) -> None: - """Refresh user settings.""" try: + await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() - except (BringAuthException, BringRequestException, BringParseException) as e: - raise UpdateFailed( - "Unable to connect and retrieve user settings from bring" + except BringRequestException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 5b6bf975764ba1..a1e0cb2edc090d 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,7 +20,7 @@ def __init__( bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, bring_list["listUuid"]) self._list_uuid = bring_list["listUuid"] diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index ff24a9913508a3..71fe733ccf5d50 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["bring_api"], "requirements": ["bring-api==0.9.1"] } diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 7331f68a161e86..ea9af03484e4f8 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -101,17 +101,17 @@ "setup_authentication_exception": { "message": "Authentication failed for {email}, check your email and password" }, - "notify_missing_argument_item": { - "message": "Failed to perform action {service}. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "notify_missing_argument": { + "message": "This action requires field {field}, please enter a valid value for {field}" }, "notify_request_failed": { - "message": "Failed to send push notification for bring due to a connection error, try again later" + "message": "Failed to send push notification for Bring! due to a connection error, try again later" } }, "services": { "send_message": { "name": "[%key:component::notify::services::notify::name%]", - "description": "Send a mobile push notification to members of a shared Bring! list.", + "description": "Sends a mobile push notification to members of a shared Bring! list.", "fields": { "entity_id": { "name": "List", @@ -122,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if message type `Urgent Message` selected)", - "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" + "name": "Item (Required if notification type 'Urgent message' is selected)", + "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } } @@ -131,10 +131,10 @@ "selector": { "notification_type_selector": { "options": { - "going_shopping": "I'm going shopping! - Last chance to make changes", - "changed_list": "List updated - Take a look at the articles", - "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent Message - Please buy `Article name` urgently" + "going_shopping": "I'm going shopping! - Last chance for adjustments", + "changed_list": "I changed the list! - Take a look at the items", + "shopping_done": "The shopping is done - Our fridge is well stocked", + "urgent_message": "Attention! Attention! - We still urgently need: [Items]" } } } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index c53b5788b68437..75657e2fd64651 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -262,8 +262,6 @@ async def async_send_message( except ValueError as e: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="notify_missing_argument_item", - translation_placeholders={ - "service": f"{DOMAIN}.{SERVICE_PUSH_NOTIFICATION}", - }, + translation_key="notify_missing_argument", + translation_placeholders={"field": "item"}, ) from e diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index c9b2fb46608535..617e466a1b18e5 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -15,7 +15,6 @@ ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IMPORT, SOURCE_REAUTH, @@ -25,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN from .helpers import format_mac @@ -65,7 +65,7 @@ async def async_set_device( } async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" host = discovery_info.ip diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index 75b6236a4731ef..082af07ebbd045 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -3,7 +3,6 @@ from contextlib import suppress from functools import partial import logging -from typing import Generic import broadlink as blk from broadlink.exceptions import ( @@ -13,7 +12,6 @@ ConnectionClosedError, NetworkTimeoutError, ) -from typing_extensions import TypeVar from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -31,8 +29,6 @@ from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES from .updater import BroadlinkUpdateManager, get_update_manager -_ApiT = TypeVar("_ApiT", bound=blk.Device, default=blk.Device) - _LOGGER = logging.getLogger(__name__) @@ -41,7 +37,7 @@ def get_domains(device_type: str) -> set[Platform]: return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} -class BroadlinkDevice(Generic[_ApiT]): +class BroadlinkDevice[_ApiT: blk.Device = blk.Device]: """Manages a Broadlink device.""" api: _ApiT diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 17c98f0182f9b0..492023afd66386 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -17,7 +17,7 @@ }, "reset": { "title": "Unlock the device", - "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Click on the device.\n3. Click `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." + "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Select the device.\n3. Select `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." }, "unlock": { "title": "Unlock the device (optional)", diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f1455f5a5411b3..8e0a521e182cfb 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -5,11 +5,10 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any, Generic +from typing import TYPE_CHECKING, Any, Generic, TypeVar import broadlink as blk from broadlink.exceptions import AuthorizationError, BroadlinkException -from typing_extensions import TypeVar from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index d9130b96300b19..f6b3f456056ba1 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -7,12 +7,12 @@ from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.snmp import async_get_snmp_engine from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_host_valid from .const import DOMAIN, PRINTER_TYPES @@ -83,7 +83,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index d60089a9bf53c8..6d194714c64bd7 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -42,6 +42,7 @@ "long_press", "long_double_press", "long_triple_press", + "hold_press", }, EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, } diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py index 128d1e8388fcbe..a6ee79f4e0557e 100644 --- a/homeassistant/components/bthome/event.py +++ b/homeassistant/components/bthome/event.py @@ -36,6 +36,7 @@ "long_press", "long_double_press", "long_triple_press", + "hold_press", ], device_class=EventDeviceClass.BUTTON, ), diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index be5e156e99cadd..1c41d5553da0fe 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -26,7 +26,7 @@ def async_describe_bthome_event(event: Event[BTHomeBleEvent]) -> dict[str, str]: """Describe bthome logbook event.""" data = event.data device = dev_reg.async_get(data["device_id"]) - name = device and device.name or f'BTHome {data["address"]}' + name = (device and device.name) or f"BTHome {data['address']}" if properties := data["event_properties"]: message = f"{data['event_class']} {data['event_type']}: {properties}" else: diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index c64028229b32ea..daf969ba80f99f 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -37,6 +37,7 @@ "long_press": "Long Press", "long_double_press": "Long Double Press", "long_triple_press": "Long Triple Press", + "hold_press": "Hold Press", "rotate_right": "Rotate Right", "rotate_left": "Rotate Left" }, @@ -56,7 +57,8 @@ "triple_press": "Triple press", "long_press": "Long press", "long_double_press": "Long double press", - "long_triple_press": "Long triple press" + "long_triple_press": "Long triple press", + "hold_press": "Hold press" } } } diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index eb09e3f5452226..c6bbd15bdff708 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -186,12 +186,12 @@ def is_matching(vevent, search): pattern = re.compile(search) return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) + (hasattr(vevent, "summary") and pattern.match(vevent.summary.value)) + or (hasattr(vevent, "location") and pattern.match(vevent.location.value)) + or ( + hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) ) @staticmethod diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py index 6f5a92feac05bb..fb0ad66c652ede 100644 --- a/homeassistant/components/cambridge_audio/config_flow.py +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -6,7 +6,6 @@ from aiostreammagic import StreamMagicClient import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -14,6 +13,7 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS @@ -30,7 +30,7 @@ def __init__(self) -> None: self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index 9f5e031815b429..6041232fe65b24 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -12,7 +12,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" }, "reconfigure": { "description": "Reconfigure your Cambridge Audio Streamer.", @@ -28,7 +28,7 @@ "cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." }, "abort": { - "wrong_device": "This Cambridge Audio device does not match the existing device id. Please make sure you entered the correct IP address.", + "wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 725fc84adc341b..16b9fb06dbbe9f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -516,6 +516,19 @@ def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -569,7 +582,7 @@ def frontend_stream_type(self) -> StreamType | None: self._deprecate_attr_frontend_stream_type_logged = True return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if ( self._webrtc_provider @@ -798,7 +811,9 @@ def async_update_token(self) -> None: async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -838,7 +853,7 @@ async def _async_get_supported_webrtc_provider[_T]( self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None return await fn(self.hass, self) @@ -896,7 +911,7 @@ def _invalidate_camera_capabilities_cache(self) -> None: def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features: + if CameraEntityFeature.STREAM in self.supported_features_compat: if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -916,7 +931,8 @@ def async_write_ha_state(self) -> None: """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features & CameraEntityFeature.STREAM + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 03a3f2ea1f84f9..034cf85602343b 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -16,6 +16,7 @@ from homeassistant.const import CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN @@ -50,7 +51,7 @@ async def async_step_user( return await self.async_step_config() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 80b00237fd3514..55ffedd2781570 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -50,7 +50,6 @@ CONF_ACCOUNTS_SERVER, CONF_ACME_SERVER, CONF_ALEXA, - CONF_ALEXA_SERVER, CONF_ALIASES, CONF_CLOUDHOOK_SERVER, CONF_COGNITO_CLIENT_ID, @@ -128,7 +127,6 @@ vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACME_SERVER): str, - vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index b67c1afad71dc7..851d658f8e0843 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -65,7 +65,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: services: list[dict[str, Any]] if DATA_SERVICES in hass.data: services = hass.data[DATA_SERVICES] - return services # noqa: RET504 + return services try: services = await account_link.async_fetch_available_services( diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 57145e52c448cd..153d0741770103 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -2,13 +2,15 @@ from __future__ import annotations +import asyncio import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging -from typing import Any, Self +import random +from typing import Any -from aiohttp import ClientError, ClientTimeout, StreamReader +from aiohttp import ClientError, ClientTimeout from hass_nabucasa import Cloud, CloudError from hass_nabucasa.cloud_api import ( async_files_delete_file, @@ -19,6 +21,7 @@ from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.dispatcher import async_dispatcher_connect from .client import CloudClient @@ -26,6 +29,9 @@ _LOGGER = logging.getLogger(__name__) _STORAGE_BACKUP = "backup" +_RETRY_LIMIT = 5 +_RETRY_SECONDS_MIN = 60 +_RETRY_SECONDS_MAX = 600 async def _b64md5(stream: AsyncIterator[bytes]) -> str: @@ -73,31 +79,6 @@ def handle_event(data: Mapping[str, Any]) -> None: return unsub -class ChunkAsyncStreamIterator: - """Async iterator for chunked streams. - - Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields - bytes instead of tuple[bytes, bool]. - """ - - __slots__ = ("_stream",) - - def __init__(self, stream: StreamReader) -> None: - """Initialize.""" - self._stream = stream - - def __aiter__(self) -> Self: - """Iterate.""" - return self - - async def __anext__(self) -> bytes: - """Yield next chunk.""" - rv = await self._stream.readchunk() - if rv == (b"", False): - raise StopAsyncIteration - return rv[0] - - class CloudBackupAgent(BackupAgent): """Cloud backup agent.""" @@ -138,37 +119,34 @@ async def async_download_backup( raise BackupAgentError("Failed to get download details") from err try: - resp = await self._cloud.websession.get(details["url"]) + resp = await self._cloud.websession.get( + details["url"], + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + resp.raise_for_status() except ClientError as err: raise BackupAgentError("Failed to download backup") from err return ChunkAsyncStreamIterator(resp.content) - async def async_upload_backup( + async def _async_do_upload_backup( self, *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - backup: AgentBackup, - **kwargs: Any, + filename: str, + base64md5hash: str, + metadata: dict[str, Any], + size: int, ) -> None: - """Upload a backup. - - :param open_stream: A function returning an async iterator that yields bytes. - :param backup: Metadata about the backup that should be uploaded. - """ - if not backup.protected: - raise BackupAgentError("Cloud backups must be protected") - - base64md5hash = await _b64md5(await open_stream()) - + """Upload a backup.""" try: details = await async_files_upload_details( self._cloud, storage_type=_STORAGE_BACKUP, - filename=self._get_backup_filename(), - metadata=backup.as_dict(), - size=backup.size, + filename=filename, + metadata=metadata, + size=size, base64md5hash=base64md5hash, ) except (ClientError, CloudError) as err: @@ -178,13 +156,63 @@ async def async_upload_backup( upload_status = await self._cloud.websession.put( details["url"], data=await open_stream(), - headers=details["headers"] | {"content-length": str(backup.size)}, + headers=details["headers"] | {"content-length": str(size)}, timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h ) + _LOGGER.log( + logging.DEBUG if upload_status.status < 400 else logging.WARNING, + "Backup upload status: %s", + upload_status.status, + ) upload_status.raise_for_status() except (TimeoutError, ClientError) as err: raise BackupAgentError("Failed to upload backup") from err + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + if not backup.protected: + raise BackupAgentError("Cloud backups must be protected") + + base64md5hash = await _b64md5(await open_stream()) + filename = self._get_backup_filename() + metadata = backup.as_dict() + size = backup.size + + tries = 1 + while tries <= _RETRY_LIMIT: + try: + await self._async_do_upload_backup( + open_stream=open_stream, + filename=filename, + base64md5hash=base64md5hash, + metadata=metadata, + size=size, + ) + break + except BackupAgentError as err: + if tries == _RETRY_LIMIT: + raise + tries += 1 + retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) + _LOGGER.info( + "Failed to upload backup, retrying (%s/%s) in %ss: %s", + tries, + _RETRY_LIMIT, + retry_timer, + err, + ) + await asyncio.sleep(retry_timer) + async def async_delete_backup( self, backup_id: str, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index cff71bacebc598..3883f19d1b7048 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -76,7 +76,6 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACME_SERVER = "acme_server" -CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7ee8cf46b86bdf..0f415b1738a846 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.87.0"], + "requirements": ["hass-nabucasa==0.88.1"], "single_config_entry": true } diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index f5fd8fa1dc3ab8..6aa33a7c14dc69 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -101,7 +101,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non if ( "xe" in entity.unique_id and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - or "wallet" in entity.unique_id + ) or ( + "wallet" in entity.unique_id and currency not in config_entry.options.get(CONF_CURRENCIES, []) ): registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index fae416e7fc24cc..e83339d2c1854f 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -42,7 +42,7 @@ def datapoints_greater_than_degree(value: dict) -> dict: if len(value[CONF_DATAPOINTS]) <= value[CONF_DEGREE]: raise vol.Invalid( f"{CONF_DATAPOINTS} must have at least" - f" {value[CONF_DEGREE]+1} {CONF_DATAPOINTS}" + f" {value[CONF_DEGREE] + 1} {CONF_DATAPOINTS}" ) return value diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index a7e714a80b8795..cdf4dd1aaa40df 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.2.1"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index c8cc9242ea42a5..b2a590928c1733 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -41,10 +41,12 @@ def websocket_list_areas( vol.Required("type"): "config/area_registry/create", vol.Optional("aliases"): list, vol.Optional("floor_id"): str, + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): str, vol.Optional("labels"): [str], vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin @@ -107,10 +109,12 @@ def websocket_delete_area( vol.Optional("aliases"): list, vol.Required("area_id"): str, vol.Optional("floor_id"): vol.Any(str, None), + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): vol.Any(str, None), vol.Optional("labels"): [str], vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index aed049439753b3..b987f249a33632 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -279,9 +279,8 @@ def websocket_update_entity( result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart - if ( - not (config_entry_id := entity_entry.config_entry_id) - or (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + if not (config_entry_id := entity_entry.config_entry_id) or ( + (config_entry := hass.config_entries.async_get_entry(config_entry_id)) and not config_entry.supports_unload ): result["require_restart"] = True diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 898b7b2cf4fabd..9c1db128f15041 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,20 +48,25 @@ from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult +from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", + "ChatMessage", + "ChatSession", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", + "ConverseError", "async_conversation_trace_append", "async_converse", "async_get_agent_info", + "async_get_chat_session", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 7516d9d22efd21..97dc5e1292e4d8 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -75,6 +75,7 @@ async def async_converse( language: str | None = None, agent_id: str | None = None, device_id: str | None = None, + extra_system_prompt: str | None = None, ) -> ConversationResult: """Process text and get intent.""" agent = async_get_agent(hass, agent_id) @@ -99,6 +100,7 @@ async def async_converse( device_id=device_id, language=language, agent_id=agent_id, + extra_system_prompt=extra_system_prompt, ) with async_conversation_trace() as trace: trace.add_event( diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 66ffb25fa1a894..1d79709adf86ba 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,6 +62,7 @@ ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult +from .session import ChatMessage, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -346,35 +347,52 @@ async def async_recognize_intent( async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + response: intent.IntentResponse | None = None + async with async_get_chat_session(self.hass, user_input) as chat_session: + # Check if a trigger matched + if trigger_result := await self.async_recognize_sentence_trigger( + user_input + ): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input + ) - # Check if a trigger matched - if trigger_result := await self.async_recognize_sentence_trigger(user_input): - # Process callbacks and get response - response_text = await self._handle_trigger_result( - trigger_result, user_input - ) + # Convert to conversation result + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language + ) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text) + + if response is None: + # Match intents + intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( + intent_result, user_input + ) - # Convert to conversation result - response = intent.IntentResponse( - language=user_input.language or self.hass.config.language + speech: str = response.speech.get("plain", {}).get("speech", "") + chat_session.async_add_message( + ChatMessage( + role="assistant", + agent_id=user_input.agent_id, + content=speech, + native=response, + ) ) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text) - return ConversationResult(response=response) - - # Match intents - intent_result = await self.async_recognize_intent(user_input) - return await self._async_process_intent_result(intent_result, user_input) + return ConversationResult( + response=response, conversation_id=chat_session.conversation_id + ) async def _async_process_intent_result( self, result: RecognizeResult | None, user_input: ConversationInput, - ) -> ConversationResult: + ) -> intent.IntentResponse: """Process user input with intents.""" language = user_input.language or self.hass.config.language - conversation_id = None # Not supported # Intent match or failure lang_intents = await self.async_get_or_load_intents(language) @@ -386,7 +404,6 @@ async def _async_process_intent_result( language, intent.IntentResponseErrorCode.NO_INTENT_MATCH, self._get_error_text(ErrorKey.NO_INTENT, lang_intents), - conversation_id, ) if result.unmatched_entities: @@ -408,7 +425,6 @@ async def _async_process_intent_result( self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) # Will never happen because result will be None when no intents are @@ -461,7 +477,6 @@ async def _async_process_intent_result( self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) except intent.IntentHandleError as err: # Intent was valid and entities matched constraints, but an error @@ -473,7 +488,6 @@ async def _async_process_intent_result( self._get_error_text( err.response_key or ErrorKey.HANDLE_ERROR, lang_intents ), - conversation_id, ) except intent.IntentUnexpectedError: _LOGGER.exception("Unexpected intent error") @@ -481,7 +495,6 @@ async def _async_process_intent_result( language, intent.IntentResponseErrorCode.UNKNOWN, self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), - conversation_id, ) if ( @@ -500,9 +513,7 @@ async def _async_process_intent_result( ) intent_response.async_set_speech(speech) - return ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + return intent_response def _recognize( self, @@ -1339,29 +1350,36 @@ async def async_handle_intents( """Try to match sentence against registered intents and return response. Only performs strict matching with exposed entities and exact wording. - Returns None if no match occurred. + Returns None if no match or a matching error occurred. """ result = await self.async_recognize_intent(user_input, strict_intents_only=True) if not isinstance(result, RecognizeResult): # No error message on failed match return None - conversation_result = await self._async_process_intent_result( - result, user_input - ) - return conversation_result.response + response = await self._async_process_intent_result(result, user_input) + if ( + response.response_type == intent.IntentResponseType.ERROR + and response.error_code + not in ( + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + intent.IntentResponseErrorCode.UNKNOWN, + ) + ): + # We ignore no matching errors + return None + return response def _make_error_result( language: str, error_code: intent.IntentResponseErrorCode, response_text: str, - conversation_id: str | None = None, -) -> ConversationResult: +) -> intent.IntentResponse: """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - return ConversationResult(response, conversation_id) + return response def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 10218e767512ca..9462c597f23bb4 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -40,6 +40,9 @@ class ConversationInput: agent_id: str | None = None """Agent to use for processing.""" + extra_system_prompt: str | None = None + """Extra prompt to provide extra info to LLMs how to understand the command.""" + def as_dict(self) -> dict[str, Any]: """Return input as a dict.""" return { @@ -49,6 +52,7 @@ def as_dict(self) -> dict[str, Any]: "device_id": self.device_id, "language": self.language, "agent_id": self.agent_id, + "extra_system_prompt": self.extra_system_prompt, } diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py new file mode 100644 index 00000000000000..426b11ea24b56d --- /dev/null +++ b/homeassistant/components/conversation/session.py @@ -0,0 +1,328 @@ +"""Conversation history.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +import logging +from typing import Literal + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import intent, llm, template +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util, ulid +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN +from .models import ConversationInput, ConversationResult + +DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey( + "conversation_chat_session" +) +DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey( + "conversation_chat_session_cleanup" +) + +LOGGER = logging.getLogger(__name__) +CONVERSATION_TIMEOUT = timedelta(minutes=5) + + +class SessionCleanup: + """Helper to clean up the history.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the history cleanup.""" + self.hass = hass + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + CONVERSATION_TIMEOUT.total_seconds() + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, now: datetime) -> None: + """Clean up the history and schedule follow-up if necessary.""" + self.unsub = None + all_history = self.hass.data[DATA_CHAT_HISTORY] + + # We mutate original object because current commands could be + # yielding history based on it. + for conversation_id, history in list(all_history.items()): + if history.last_updated + CONVERSATION_TIMEOUT < now: + del all_history[conversation_id] + + # Still conversations left, check again in timeout time. + if all_history: + self.schedule() + + +@asynccontextmanager +async def async_get_chat_session( + hass: HomeAssistant, + user_input: ConversationInput, +) -> AsyncGenerator[ChatSession]: + """Return chat session.""" + all_history = hass.data.get(DATA_CHAT_HISTORY) + if all_history is None: + all_history = {} + hass.data[DATA_CHAT_HISTORY] = all_history + hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass) + + history: ChatSession | None = None + + if user_input.conversation_id is None: + conversation_id = ulid.ulid_now() + + elif history := all_history.get(user_input.conversation_id): + conversation_id = user_input.conversation_id + + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old OLID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid.ulid_now() + except ValueError: + conversation_id = user_input.conversation_id + + if history: + history = replace(history, messages=history.messages.copy()) + else: + history = ChatSession(hass, conversation_id) + + message: ChatMessage = ChatMessage( + role="user", + agent_id=user_input.agent_id, + content=user_input.text, + ) + history.async_add_message(message) + + yield history + + if history.messages[-1] is message: + LOGGER.debug( + "History opened but no assistant message was added, ignoring update" + ) + return + + history.last_updated = dt_util.utcnow() + all_history[conversation_id] = history + hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule() + + +class ConverseError(HomeAssistantError): + """Error during initialization of conversation. + + Will not be stored in the history. + """ + + def __init__( + self, message: str, conversation_id: str, response: intent.IntentResponse + ) -> None: + """Initialize the error.""" + super().__init__(message) + self.conversation_id = conversation_id + self.response = response + + def as_conversation_result(self) -> ConversationResult: + """Return the error as a conversation result.""" + return ConversationResult( + response=self.response, + conversation_id=self.conversation_id, + ) + + +@dataclass +class ChatMessage[_NativeT]: + """Base class for chat messages. + + When role is native, the content is to be ignored and message + is only meant for storing the native object. + """ + + role: Literal["system", "assistant", "user", "native"] + agent_id: str | None + content: str + native: _NativeT | None = field(default=None) + + # Validate in post-init that if role is native, there is no content and a native object exists + def __post_init__(self) -> None: + """Validate native message.""" + if self.role == "native" and self.native is None: + raise ValueError("Native message must have a native object") + + +@dataclass +class ChatSession[_NativeT]: + """Class holding all information for a specific conversation.""" + + hass: HomeAssistant + conversation_id: str + user_name: str | None = None + messages: list[ChatMessage[_NativeT]] = field( + default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + ) + extra_system_prompt: str | None = None + llm_api: llm.APIInstance | None = None + last_updated: datetime = field(default_factory=dt_util.utcnow) + + @callback + def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + """Process intent.""" + if message.role == "system": + raise ValueError("Cannot add system messages to history") + if message.role != "native" and self.messages[-1].role == message.role: + raise ValueError("Cannot add two assistant or user messages in a row") + + self.messages.append(message) + + @callback + def async_get_messages(self, agent_id: str | None) -> list[ChatMessage[_NativeT]]: + """Get messages for a specific agent ID. + + This will filter out any native message tied to other agent IDs. + It can still include assistant/user messages generated by other agents. + """ + return [ + message + for message in self.messages + if message.role != "native" or message.agent_id == agent_id + ] + + async def async_update_llm_data( + self, + conversing_domain: str, + user_input: ConversationInput, + user_llm_hass_api: str | None = None, + user_llm_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" + llm_context = llm.LLMContext( + platform=conversing_domain, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=DOMAIN, + device_id=user_input.device_id, + ) + + llm_api: llm.APIInstance | None = None + + if user_llm_hass_api: + try: + llm_api = await llm.async_get_api( + self.hass, + user_llm_hass_api, + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error( + "Error getting LLM API %s for %s: %s", + user_llm_hass_api, + conversing_domain, + err, + ) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Error preparing LLM API", + ) + raise ConverseError( + f"Error getting LLM API {user_llm_hass_api}", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + user_name: str | None = None + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + try: + prompt_parts = [ + template.Template( + llm.BASE_PROMPT + + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + ] + + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + if llm_api: + prompt_parts.append(llm_api.api_prompt) + + extra_system_prompt = ( + # Take new system prompt if one was given + user_input.extra_system_prompt or self.extra_system_prompt + ) + + if extra_system_prompt: + prompt_parts.append(extra_system_prompt) + + prompt = "\n".join(prompt_parts) + + self.llm_api = llm_api + self.user_name = user_name + self.extra_system_prompt = extra_system_prompt + self.messages[0] = ChatMessage( + role="system", + agent_id=user_input.agent_id, + content=prompt, + ) diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 48c37c64db0506..67095422e65006 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,41 +2,29 @@ from __future__ import annotations -from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options - -from homeassistant.const import ( - CONF_COUNTRY, - CONF_EMAIL, - CONF_LANGUAGE, - CONF_PASSWORD, - Platform, -) +import logging + +from cookidoo_api import CookidooAuthException, CookidooRequestException + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .helpers import cookidoo_from_config_entry PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Set up Cookidoo from a config entry.""" - localizations = await get_localization_options( - country=entry.data[CONF_COUNTRY].lower(), - language=entry.data[CONF_LANGUAGE], - ) - - cookidoo = Cookidoo( - async_get_clientsession(hass), - CookidooConfig( - email=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - localization=localizations[0], - ), + coordinator = CookidooDataUpdateCoordinator( + hass, await cookidoo_from_config_entry(hass, entry), entry ) - - coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -49,3 +37,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: CookidooConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1 and config_entry.minor_version == 1: + # Add the unique uuid + cookidoo = await cookidoo_from_config_entry(hass, config_entry) + + try: + auth_data = await cookidoo.login() + except (CookidooRequestException, CookidooAuthException) as e: + _LOGGER.error( + "Could not migrate config config_entry: %s", + str(e), + ) + return False + + unique_id = auth_data.sub + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_update_device( + dev.id, new_identifiers={(DOMAIN, unique_id)} + ) + for ent in entity_entries: + assert ent.config_entry_id + entity_registry.async_update_entity( + ent.entity_id, + new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id), + ) + + hass.config_entries.async_update_entry( + config_entry, unique_id=auth_data.sub, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py index 2a20a156db4538..b292a7309bae32 100644 --- a/homeassistant/components/cookidoo/button.py +++ b/homeassistant/components/cookidoo/button.py @@ -56,7 +56,8 @@ def __init__( """Initialize cookidoo button.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 80487ed757f8c0..71ad30157304b7 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -7,9 +7,7 @@ from typing import Any from cookidoo_api import ( - Cookidoo, CookidooAuthException, - CookidooConfig, CookidooRequestException, get_country_options, get_localization_options, @@ -23,7 +21,6 @@ ConfigFlowResult, ) from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, @@ -35,6 +32,7 @@ ) from .const import DOMAIN +from .helpers import cookidoo_from_config_data _LOGGER = logging.getLogger(__name__) @@ -57,10 +55,14 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Cookidoo.""" + VERSION = 1 + MINOR_VERSION = 2 + COUNTRY_DATA_SCHEMA: dict LANGUAGE_DATA_SCHEMA: dict user_input: dict[str, Any] + user_uuid: str async def async_step_reconfigure( self, user_input: dict[str, Any] @@ -78,8 +80,11 @@ async def async_step_user( if user_input is not None and not ( errors := await self.validate_input(user_input) ): + await self.async_set_unique_id(self.user_uuid) if self.source == SOURCE_USER: - self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() self.user_input = user_input return await self.async_step_language() await self.generate_country_schema() @@ -153,10 +158,8 @@ async def async_step_reauth_confirm( if not ( errors := await self.validate_input({**reauth_entry.data, **user_input}) ): - if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]: - self._async_abort_entries_match( - {CONF_EMAIL: user_input[CONF_EMAIL]} - ) + await self.async_set_unique_id(self.user_uuid) + self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( reauth_entry, data_updates=user_input ) @@ -220,21 +223,10 @@ async def validate_input( await get_localization_options(country=data_input[CONF_COUNTRY].lower()) )[0].language # Pick any language to test login - localizations = await get_localization_options( - country=data_input[CONF_COUNTRY].lower(), - language=data_input[CONF_LANGUAGE], - ) - - cookidoo = Cookidoo( - async_get_clientsession(self.hass), - CookidooConfig( - email=data_input[CONF_EMAIL], - password=data_input[CONF_PASSWORD], - localization=localizations[0], - ), - ) + cookidoo = await cookidoo_from_config_data(self.hass, data_input) try: - await cookidoo.login() + auth_data = await cookidoo.login() + self.user_uuid = auth_data.sub if language_input: await cookidoo.get_additional_items() except CookidooRequestException: diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py index 5c8f3ec844118a..97ebb384ecbe18 100644 --- a/homeassistant/components/cookidoo/entity.py +++ b/homeassistant/components/cookidoo/entity.py @@ -21,10 +21,12 @@ def __init__( """Initialize the entity.""" super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name="Cookidoo", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, manufacturer="Vorwerk International & Co. KmG", model="Cookidoo - Thermomix® recipe portal", ) diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py new file mode 100644 index 00000000000000..199abb2e05de44 --- /dev/null +++ b/homeassistant/components/cookidoo/helpers.py @@ -0,0 +1,37 @@ +"""Helpers for cookidoo.""" + +from typing import Any + +from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options + +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CookidooConfigEntry + + +async def cookidoo_from_config_data( + hass: HomeAssistant, data: dict[str, Any] +) -> Cookidoo: + """Build cookidoo from config data.""" + localizations = await get_localization_options( + country=data[CONF_COUNTRY].lower(), + language=data[CONF_LANGUAGE], + ) + + return Cookidoo( + async_get_clientsession(hass), + CookidooConfig( + email=data[CONF_EMAIL], + password=data[CONF_PASSWORD], + localization=localizations[0], + ), + ) + + +async def cookidoo_from_config_entry( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> Cookidoo: + """Build cookidoo from config entry.""" + return await cookidoo_from_config_data(hass, dict(entry.data)) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index b1a3e9c0267a22..5264e47a70993a 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.11.2"] + "requirements": ["cookidoo-api==0.12.2"] } diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 83cc182be16efd..8a2a288d11b5c3 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup {cookidoo}", + "title": "Set up {cookidoo}", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", @@ -15,7 +15,7 @@ } }, "language": { - "title": "Setup {cookidoo}", + "title": "[%key:component::cookidoo::config::step::user::title%]", "data": { "language": "[%key:common::config_flow::data::language%]" }, @@ -24,7 +24,7 @@ } }, "reauth_confirm": { - "title": "Login again to {cookidoo}", + "title": "Log in again to {cookidoo}", "description": "Please log in to {cookidoo} again to continue using this integration.", "data": { "email": "[%key:common::config_flow::data::email%]", @@ -44,7 +44,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The user identifier does not match the previous identifier" } }, "entity": { diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py index 4a70dadc65aa3a..3d5264f4e013ab 100644 --- a/homeassistant/components/cookidoo/todo.py +++ b/homeassistant/components/cookidoo/todo.py @@ -52,7 +52,8 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients" @property def todo_items(self) -> list[TodoItem]: @@ -112,7 +113,8 @@ class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items" @property def todo_items(self) -> list[TodoItem]: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 9ce526712f0363..c4795e0e7d9ef1 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,6 +300,10 @@ def state_attributes(self) -> dict[str, Any]: def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: + if type(features) is int: + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features return features supported_features = ( diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 5956d31c5fbbe9..cc25a88ae39d7c 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -14,10 +14,10 @@ from pydaikin.factory import DaikinFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -142,7 +142,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered Daikin device.""" _LOGGER.debug("Zeroconf user_input: %s", discovery_info) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index ed54701f656913..7f5fc96c0221fd 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -19,7 +19,6 @@ ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_HASSIO, ConfigEntry, @@ -31,6 +30,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import ( CONF_ALLOW_CLIP_SENSOR, @@ -220,13 +220,13 @@ async def async_step_reauth( return await self.async_step_link() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered deCONZ bridge.""" if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) parsed_url = urlparse(discovery_info.ssdp_location) entry = await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/decorquip/__init__.py b/homeassistant/components/decorquip/__init__.py new file mode 100644 index 00000000000000..2fd6dc0efce7ab --- /dev/null +++ b/homeassistant/components/decorquip/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Decorquip.""" diff --git a/homeassistant/components/decorquip/manifest.json b/homeassistant/components/decorquip/manifest.json new file mode 100644 index 00000000000000..769b0bf944188e --- /dev/null +++ b/homeassistant/components/decorquip/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "decorquip", + "name": "Decorquip Dream", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index f4608b370066f0..9b07ae9c875b93 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bo await hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex - except Exception as ex: # noqa: BLE001 + except Exception as ex: if type(ex).__name__ == "BadLoginError": raise ConfigEntryAuthFailed( "Credentials for Deluge client are not valid" diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 0a6fe18d9869f0..2f46cd422943f4 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 9ff0541158833f..9601b67081c154 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,6 @@ from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,13 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .receiver import ConnectDenonAVR @@ -232,7 +238,7 @@ async def async_step_connect( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Denon AVR. @@ -241,22 +247,20 @@ async def async_step_ssdp( """ # Filter out non-Denon AVRs#1 if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) not in SUPPORTED_MANUFACTURERS ): return self.async_abort(reason="not_denonavr_manufacturer") # Check if required information is present to set the unique_id if ( - ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp + ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_denonavr_missing") - self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace( - "*", "" - ) - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME].replace("*", "") + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] assert discovery_info.ssdp_location is not None self.host = urlparse(discovery_info.ssdp_location).hostname @@ -270,9 +274,7 @@ async def async_step_ssdp( self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 77ce5169d8d857..988da5e938b504 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -272,7 +272,7 @@ def calculate_weight( if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = Decimal(0.00) + derivative = Decimal("0.00") for start, end, value in self._state_list: weight = calculate_weight(start, end, new_state.last_updated) derivative = derivative + (value * Decimal(weight)) diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py index 41acfa4b5a72ed..45a00fc4073430 100644 --- a/homeassistant/components/devialet/config_flow.py +++ b/homeassistant/components/devialet/config_flow.py @@ -8,10 +8,10 @@ from devialet.devialet_api import DevialetApi import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -70,7 +70,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Devialet device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5dff5837b4b212..b1520866bb52f5 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -978,9 +978,9 @@ def scan_devices(self) -> list[str]: async def async_scan_devices(self) -> list[str]: """Scan for devices.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.scan_devices) def get_device_name(self, device: str) -> str | None: @@ -989,9 +989,9 @@ def get_device_name(self, device: str) -> str | None: async def async_get_device_name(self, device: str) -> str | None: """Get the name of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_device_name, device) def get_extra_attributes(self, device: str) -> dict: @@ -1000,9 +1000,9 @@ def get_extra_attributes(self, device: str) -> dict: async def async_get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_extra_attributes, device) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index e15204af7c2a36..c4f57b2398a7ac 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,7 +7,6 @@ import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -16,6 +15,7 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo from .const import DOMAIN, SUPPORTED_MODEL_TYPES @@ -48,7 +48,7 @@ async def async_step_user( return self._show_form(step_id="user", errors={"base": "invalid_auth"}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index 7c8dccd1a7b3cb..bd2f23d602fc89 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE @@ -81,7 +82,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.properties["MT"] in ["2600", "2601"]: diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index d10e14f908151b..9b1e181d7c022e 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -3,6 +3,7 @@ "name": "devolo Home Network", "codeowners": ["@2Fake", "@Shutgun"], "config_flow": true, + "dependencies": ["zeroconf"], "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 2de676ef52a857..a11a0b262b0eba 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial import itertools import logging import re @@ -44,12 +44,17 @@ State, callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -57,6 +62,7 @@ async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp @@ -74,13 +80,11 @@ _LOGGER = logging.getLogger(__name__) -@dataclass(slots=True) -class DhcpServiceInfo(BaseServiceInfo): - """Prepared info from dhcp entries.""" - - ip: str - hostname: str - macaddress: str +_DEPRECATED_DhcpServiceInfo = DeprecatedConstant( + _DhcpServiceInfo, + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + "2026.2", +) @dataclass(slots=True) @@ -296,7 +300,7 @@ def async_process_client( self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - DhcpServiceInfo( + _DhcpServiceInfo( ip=ip_address, hostname=lowercase_hostname, macaddress=mac_address, @@ -486,3 +490,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# 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/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 1e0577b4f7c955..927d2325c2d028 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -9,11 +9,11 @@ from directv import DIRECTV, DIRECTVError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import CONF_RECEIVER_ID, DOMAIN @@ -67,7 +67,7 @@ async def async_step_user( return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP discovery.""" # We can cast the hostname to str because the ssdp_location is not bytes and @@ -75,10 +75,8 @@ async def async_step_ssdp( host = cast(str, urlparse(discovery_info.ssdp_location).hostname) receiver_id = None - if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): - receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][ - 4: - ] # strips off RID- + if discovery_info.upnp.get(ATTR_UPNP_SERIAL): + receiver_id = discovery_info.upnp[ATTR_UPNP_SERIAL][4:] # strips off RID- self.context.update({"title_placeholders": {"name": host}}) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index b82f28a5d11695..2f74928c19e7e2 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 3caeaa6bbe03b5..56af1d97304b17 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_descriptions are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt @@ -19,7 +16,7 @@ rules: The integration does not provide any additional actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -41,11 +38,11 @@ rules: status: exempt comment: | The integration does not provide any additional options. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 531904c8740f16..a3ec132db9b747 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -28,6 +28,8 @@ from .const import DOMAIN, MANUFACTURER from .coordinator import DiscovergyUpdateCoordinator +PARALLEL_UPDATES = 0 + def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: """Get a value from a Reading and divide with scale it.""" diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index b626a11ea1ea36..0058f874a3682c 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -5,6 +5,10 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to your inexogy account.", + "password": "The password used to log in to your inexogy account." } } }, @@ -15,7 +19,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "account_mismatch": "The inexogy account authenticated with, does not match the account needed re-authentication.", + "account_mismatch": "The inexogy account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4452a2958fc3c5..02ef94dae7dcbd 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -8,9 +8,9 @@ from pyW215.pyW215 import SmartPlug import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -25,7 +25,7 @@ def __init__(self) -> None: self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 75f50192500d44..ede9119c50dd7c 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -27,6 +27,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import IntegrationError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -60,7 +68,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._udn: str | None = None self._device_type: str | None = None @@ -98,7 +106,7 @@ async def async_step_user(self, user_input: FlowInput = None) -> ConfigFlowResul return await self.async_step_manual() self._discoveries = { - discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or cast(str, urlparse(discovery.ssdp_location).hostname): discovery for discovery in discoveries } @@ -131,7 +139,7 @@ async def async_step_manual(self, user_input: FlowInput = None) -> ConfigFlowRes ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -267,7 +275,7 @@ def _create_entry(self) -> ConfigFlowResult: return self.async_create_entry(title=title, data=data, options=self._options) async def _async_set_info_from_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True + self, discovery_info: SsdpServiceInfo, abort_if_configured: bool = True ) -> None: """Set information required for a config entry from the SSDP discovery.""" LOGGER.debug( @@ -285,7 +293,7 @@ async def _async_set_info_from_discovery( self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) @@ -301,12 +309,12 @@ async def _async_set_info_from_discovery( updates[CONF_MAC] = self._mac self._abort_if_unique_id_configured(updates=updates, reload_on_update=False) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" LOGGER.debug("_get_discoveries") # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmrDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st @@ -386,7 +394,7 @@ def _add_with_suggestion(key: str, validator: Callable | type[bool]) -> None: ) -def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_ignored_device(discovery_info: SsdpServiceInfo) -> bool: """Return True if this device should be ignored for discovery. These devices are supported better by other integrations, so don't bug @@ -402,17 +410,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return True # Is the root device not a DMR? - if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE) - not in DmrDevice.DEVICE_TYPES - ): + if discovery_info.upnp.get(ATTR_UPNP_DEVICE_TYPE) not in DmrDevice.DEVICE_TYPES: return True # Special cases for devices with other discovery methods (e.g. mDNS), or # that advertise multiple unrelated (sent in separate discovery packets) # UPnP devices. - manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower() - model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower() + manufacturer = (discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) or "").lower() + model = (discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "").lower() if manufacturer.startswith("xbmc") or model == "kodi": # kodi @@ -432,14 +437,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return False -def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_dmr_device(discovery_info: SsdpServiceInfo) -> bool: """Determine if discovery is a complete DLNA DMR device. Use the discovery_info instead of DmrDevice.is_profile_device to avoid contacting the device again. """ # Abort if the device doesn't support all services required for a DmrDevice. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return False diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index af16379e9c90db..adbb4198b9ffbf 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 443c2101302911..563ed209b7de25 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -33,6 +33,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_BROWSE_UNFILTERED, @@ -246,7 +247,7 @@ async def async_will_remove_from_hass(self) -> None: await self._device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" _LOGGER.debug( diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index ad959ece3b61b3..a87b4a510f5d94 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -14,6 +14,11 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN from .util import generate_source_id @@ -33,7 +38,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._usn: str | None = None self._name: str | None = None @@ -60,14 +65,14 @@ async def async_step_user( } discovery_choices = { - host: f"{discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)} ({host})" + host: f"{discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME)} ({host})" for host, discovery in self._discoveries.items() } data_schema = vol.Schema({vol.Optional(CONF_HOST): vol.In(discovery_choices)}) return self.async_show_form(step_id="user", data_schema=data_schema) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -81,7 +86,7 @@ async def async_step_ssdp( # Abort if the device doesn't support all services required for a DmsDevice. # Use the discovery_info instead of DmsDevice.is_profile_device to avoid # contacting the device again. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dms") @@ -135,7 +140,7 @@ def _create_entry(self) -> ConfigFlowResult: return self.async_create_entry(title=self._name, data=data) async def _async_parse_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True + self, discovery_info: SsdpServiceInfo, raise_on_progress: bool = True ) -> None: """Get required details from an SSDP discovery. @@ -162,15 +167,15 @@ async def _async_parse_discovery( ) self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmsDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8f475d53280de1..1d0b27696f7369 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -29,6 +29,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_SOURCE_ID, @@ -220,7 +221,7 @@ async def async_will_remove_from_hass(self) -> None: await self.device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" LOGGER.debug( diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 51633d0e05d142..7b055c6dd05e56 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -266,7 +266,7 @@ def _save_image(self, image, matches, paths): # Draw detected objects for instance in values: - box_label = f'{label} {instance["score"]:.1f}%' + box_label = f"{label} {instance['score']:.1f}%" # Already scaled, use 1 for width and height draw_box( draw, diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index ebb1d6fc126059..6a954f5310ffd4 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -11,7 +11,6 @@ from doorbirdpy import DoorBird import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -158,7 +158,7 @@ async def async_step_user( return self.async_show_form(step_id="user", data_schema=data, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 213e948bafb91e..e05785b8b2615a 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -20,6 +20,7 @@ import serial from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -456,24 +457,29 @@ def rename_old_gas_to_mbus( if entity.unique_id.endswith( "belgium_5min_gas_meter_reading" ) or entity.unique_id.endswith("hourly_gas_meter_reading"): - try: - ent_reg.async_update_entity( - entity.entity_id, - new_unique_id=mbus_device_id, - device_id=mbus_device_id, - ) - except ValueError: + if ent_reg.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, mbus_device_id + ): LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) - else: - LOGGER.debug( - "Migrated entity %s from unique id %s to %s", - entity.entity_id, - entity.unique_id, - mbus_device_id, - ) + continue + new_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, mbus_device_id)}, + ) + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=new_device.id, + ) + LOGGER.debug( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) # Cleanup old device dev_entities = er.async_entries_for_device( ent_reg, device_id, include_disabled_entities=True diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 68b7db12d45010..2b0ae46b405ba6 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -85,7 +85,7 @@ async def _async_update_data(self) -> None: ) continue - id_prefix = f"{meter["serviceType"].lower()}_{serial_number}" + id_prefix = f"{meter['serviceType'].lower()}_{serial_number}" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( @@ -136,7 +136,7 @@ async def _async_update_data(self) -> None: ) name_prefix = ( - f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}" + f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( has_mean=False, diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 709926d8496029..bfb2635481cab3 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -603,7 +603,7 @@ def remote_sensor_devices(self) -> list: """Return the remote sensor device name_by_user or name for the thermostat.""" return sorted( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) @@ -873,7 +873,7 @@ def set_sensors_used_in_climate( translation_placeholders={ "options": ", ".join( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 28cfbebe506894..70860003b2ae54 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -34,7 +34,7 @@ def __init__(self, data: EcobeeData, thermostat_index: int) -> None: """Initialize the thermostat.""" super().__init__(data, thermostat_index) self._attr_unique_id = ( - f"{self.thermostat["identifier"]}_notify_{thermostat_index}" + f"{self.thermostat['identifier']}_notify_{thermostat_index}" ) def send_message(self, message: str, title: str | None = None) -> None: diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 67d18c4784cd84..157d5b4a5eac45 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] } diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index 6994c6f65b5aa9..c6535608b0c336 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -10,11 +10,11 @@ from eheimdigital.hub import EheimDigitalHub import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 159aecd6b6cef7..7747ca4f95d7ef 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.3"], + "requirements": ["eheimdigital==1.0.5"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 040d38d272c390..1de92f95e43851 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -1,5 +1,7 @@ """Constants for the ElevenLabs text-to-speech integration.""" +ATTR_MODEL = "model" + CONF_VOICE = "voice" CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b89e966593f4dc..008cd106615bf4 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -24,6 +24,7 @@ from . import ElevenLabsConfigEntry from .const import ( + ATTR_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, @@ -85,7 +86,7 @@ async def async_setup_entry( class ElevenLabsTTSEntity(TextToSpeechEntity): """The ElevenLabs API entity.""" - _attr_supported_options = [ATTR_VOICE] + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] _attr_entity_category = EntityCategory.CONFIG def __init__( @@ -141,13 +142,14 @@ async def async_get_tts_audio( _LOGGER.debug("Getting TTS audio for %s", message) _LOGGER.debug("Options: %s", options) voice_id = options.get(ATTR_VOICE, self._default_voice_id) + model = options.get(ATTR_MODEL, self._model.model_id) try: audio = await self._client.generate( text=message, voice=voice_id, optimize_streaming_latency=self._latency, voice_settings=self._voice_settings, - model=self._model.model_id, + model=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) except ApiError as exc: diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index e20afc73a2d8b9..a47f039384ca2b 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -7,11 +7,12 @@ from elgato import Elgato, ElgatoError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -43,7 +44,7 @@ async def async_step_user( return self._async_create_entry() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index a3dd1d46f8bc58..c486a385721c44 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -9,7 +9,6 @@ from elkm1_lib.elk import Elk import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ADDRESS, @@ -21,6 +20,7 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util import slugify from homeassistant.util.network import is_ip_address @@ -140,7 +140,7 @@ def __init__(self) -> None: self._discovered_devices: dict[str, ElkSystem] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = ElkSystem( diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 7822307e12eddf..12c22e23ff067f 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.10"] + "requirements": ["elkm1-lib==2.2.11"] } diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index bf02d7272800b7..f184483646dab9 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -89,7 +89,7 @@ }, "alarm_arm_vacation": { "name": "Alarm arm vacation", - "description": "Arm the ElkM1 in vacation mode.", + "description": "Arms the ElkM1 in vacation mode.", "fields": { "code": { "name": "Code", diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 09e0bc0d260742..b86975526264de 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -12,9 +12,9 @@ import httpx import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( build_direct_ssl_context, diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 0cd686b5b56710..581948bbc6f06f 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -26,7 +26,7 @@ def _migrate_unique_id( for entity in entry_entities: if entity.unique_id.split("-")[0] == entry.entry_id: feed_id = entity.unique_id.split("-")[-1] - LOGGER.debug(f"moving feed {feed_id} to hardware uuid") + LOGGER.debug("moving feed %s to hardware uuid", feed_id) ent_reg.async_update_entity( entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}" ) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 5769e8259449ac..77216a3fb2ff86 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -10,8 +10,8 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "url": "Server url starting with the protocol (http or https)", - "api_key": "Your 32 bits api key" + "url": "Server URL starting with the protocol (http or https)", + "api_key": "Your 32 bits API key" } }, "choose_feeds": { @@ -93,7 +93,7 @@ }, "migrate_database": { "title": "Upgrade your emoncms version", - "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})" + "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})" } } } diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index 833b80f9d47f82..458eb5ae3c7b2f 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -7,12 +7,12 @@ import aiohttp import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import name_short_mac from .const import DOMAIN @@ -69,7 +69,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.discovered_ip = discovery_info.ip diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index e13112f20bb002..464d2bcb7e7e17 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -865,7 +865,7 @@ def state_supports_hue_brightness( return False features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) enum = ENTITY_FEATURES_BY_DOMAIN[domain] - features = enum(features) if type(features) is int else features # noqa: E721 + features = enum(features) if type(features) is int else features return required_feature in features diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 147d8f3e26a363..199d18d6b07322 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -362,12 +362,11 @@ def _update_cost(self) -> None: return if ( - ( - state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) - ) - or state_class == SensorStateClass.TOTAL_INCREASING + state_class != SensorStateClass.TOTAL_INCREASING + and energy_state.attributes.get(ATTR_LAST_RESET) + != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + ) or ( + state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.stat_energy_key]), diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 7d6887ad14ce93..2bb299722b7c5c 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.3.0"] + "requirements": ["openwebifpy==4.3.1"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index ee0de15c3fb7f7..1012997ff7fc13 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -56,6 +56,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.PLAY ) def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None: diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 9d9699481b1a97..1a6f08cbf379c8 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "detect": { - "title": "Select the path to your ENOcean dongle", + "title": "Select the path to your EnOcean dongle", "data": { "path": "USB dongle path" } }, "manual": { - "title": "Enter the path to your ENOcean dongle", + "title": "Enter the path to your EnOcean dongle", "data": { "path": "[%key:component::enocean::config::step::detect::data::path%]" } diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 1a2186d305e75b..654e2262730a5b 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -10,10 +10,8 @@ from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -21,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -43,12 +42,28 @@ async def validate_input( - hass: HomeAssistant, host: str, username: str, password: str + hass: HomeAssistant, + host: str, + username: str, + password: str, + errors: dict[str, str], + description_placeholders: dict[str, str], ) -> Envoy: """Validate the user input allows us to connect.""" envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) - await envoy.setup() - await envoy.authenticate(username=username, password=password) + try: + await envoy.setup() + await envoy.authenticate(username=username, password=password) + except INVALID_AUTH_ERRORS as e: + errors["base"] = "invalid_auth" + description_placeholders["reason"] = str(e) + except EnvoyError as e: + errors["base"] = "cannot_connect" + description_placeholders["reason"] = str(e) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return envoy @@ -57,8 +72,6 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _reauth_entry: ConfigEntry - def __init__(self) -> None: """Initialize an envoy flow.""" self.ip_address: str | None = None @@ -110,7 +123,7 @@ def _async_current_hosts(self) -> set[str]: } async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if _LOGGER.isEnabledFor(logging.DEBUG): @@ -159,10 +172,43 @@ async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._reauth_entry = self._get_reauth_entry() - if unique_id := self._reauth_entry.unique_id: - await self.async_set_unique_id(unique_id, raise_on_progress=False) - return await self.async_step_user() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + await validate_input( + self.hass, + reauth_entry.data[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + serial = reauth_entry.unique_id or "-" + self.context["title_placeholders"] = { + CONF_SERIAL: serial, + CONF_HOST: reauth_entry.data[CONF_HOST], + } + description_placeholders["serial"] = serial + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self._async_generate_schema(), + description_placeholders=description_placeholders, + errors=errors, + ) def _async_envoy_name(self) -> str: """Return the name of the envoy.""" @@ -174,38 +220,20 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} - - if self.source == SOURCE_REAUTH: - host = self._reauth_entry.data[CONF_HOST] - else: - host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" + host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" if user_input is not None: - try: - envoy = await validate_input( - self.hass, - host, - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: name = self._async_envoy_name() - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, - data=self._reauth_entry.data | user_input, - ) - if not self.unique_id: await self.async_set_unique_id(envoy.serial_number) name = self._async_envoy_name() @@ -251,23 +279,15 @@ async def async_step_reconfigure( host: str = user_input[CONF_HOST] username: str = user_input[CONF_USERNAME] password: str = user_input[CONF_PASSWORD] - try: - envoy = await validate_input( - self.hass, - host, - username, - password, - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + username, + password, + errors, + description_placeholders, + ) + if not errors: await self.async_set_unique_id(envoy.serial_number) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -279,10 +299,12 @@ async def async_step_reconfigure( }, ) + serial = reconfigure_entry.unique_id or "-" self.context["title_placeholders"] = { - CONF_SERIAL: reconfigure_entry.unique_id or "-", + CONF_SERIAL: serial, CONF_HOST: reconfigure_entry.data[CONF_HOST], } + description_placeholders["serial"] = serial suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index a7038b4e0da7ae..9e5b3a5921e90d 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -8,15 +8,10 @@ rules: comment: fixed 1 minute cycle based on Enphase Envoy device characteristics brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: | - - test_zero_conf_malformed_serial_property - with pytest.raises(KeyError) as ex:: - I don't believe this should be able to raise a KeyError Shouldn't we abort the flow? + config-flow-test-coverage: done config-flow: status: todo - comment: | - - async_step_reaut L160: I believe that the unique is already set when starting a reauth flow + comment: Even though redundant as explained in PR133726, add data-description fields for config-flow steps dependency-transparency: done docs-actions: status: done @@ -60,11 +55,7 @@ rules: status: done comment: pending https://github.com/home-assistant/core/pull/132373 reauthentication-flow: done - test-coverage: - status: todo - comment: | - - test_config_different_unique_id -> unique_id set to the mock config entry is an int, not a str - - Apart from the coverage, test_option_change_reload does not verify that the config entry is reloaded + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index a78d0bc032a6a8..9747fa35a82ea3 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -23,6 +23,13 @@ "data_description": { "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]" } + }, + "reauth_confirm": { + "description": "[%key:component::enphase_envoy::config::step::user::description%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 13e9496a9fd8f4..5934c9a6f683ef 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,6 +5,7 @@ from aioesphomeapi import APIClient from homeassistant.components import ffmpeg, zeroconf +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -86,4 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" + if mac_address := entry.unique_id: + async_remove_scanner(hass, mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 004bea1835ded5..da342913d3deaa 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -11,6 +11,7 @@ from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback +from .const import DOMAIN from .entry_data import RuntimeEntryData @@ -38,7 +39,13 @@ def async_connect_scanner( return partial( _async_unload, [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=DOMAIN, + source_model=device_info.model, + source_config_entry_id=entry_data.entry_id, + ), scanner.async_setup(), ], ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index cb892b314cd06c..695131b19f75e2 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -20,7 +20,7 @@ import aiohttp import voluptuous as vol -from homeassistant.components import dhcp, zeroconf +from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,8 +31,10 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.json import json_loads_object from .const import ( @@ -223,7 +225,7 @@ async def async_step_discovery_confirm( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac_address: str | None = discovery_info.properties.get("mac") @@ -293,7 +295,7 @@ async def async_step_mqtt( return await self.async_step_discovery_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index b0a37aefd0d2d9..334c16e57301ad 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -12,6 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .coordinator import ESPHomeDashboardCoordinator @@ -19,7 +20,9 @@ _LOGGER = logging.getLogger(__name__) -KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" +KEY_DASHBOARD_MANAGER: HassKey[ESPHomeDashboardManager] = HassKey( + "esphome_dashboard_manager" +) STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 @@ -33,7 +36,7 @@ async def async_setup(hass: HomeAssistant) -> None: await async_get_or_create_dashboard_manager(hass) -@singleton(KEY_DASHBOARD_MANAGER) +@singleton(KEY_DASHBOARD_MANAGER, async_=True) async def async_get_or_create_dashboard_manager( hass: HomeAssistant, ) -> ESPHomeDashboardManager: @@ -140,7 +143,7 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | No where manager can be an asyncio.Event instead of the actual manager because the singleton decorator is not yet done. """ - manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) + manager = hass.data.get(KEY_DASHBOARD_MANAGER) return manager.async_get() if manager else None diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index dfd318c0c7419e..b382622281ea7d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -24,7 +24,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import tag, zeroconf +from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, CONF_MODE, @@ -134,16 +134,16 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( - "hass", - "host", - "password", - "entry", "cli", "device_id", "domain_data", + "entry", + "entry_data", + "hass", + "host", + "password", "reconnect_logic", "zeroconf_instance", - "entry_data", ) def __init__( @@ -425,6 +425,8 @@ async def _on_connnect(self) -> None: entry_data.disconnect_callbacks.add( async_connect_scanner(hass, entry_data, cli, device_info) ) + else: + bluetooth.async_remove_scanner(hass, device_info.mac_address) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b04fa4db428c77..f56f8342df6809 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==28.0.0", + "aioesphomeapi==28.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.0.0" ], diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index c4a8fb2d0af1ca..5bdf107f0c3de3 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -42,8 +42,8 @@ class EventDeviceClass(StrEnum): "ATTR_EVENT_TYPE", "ATTR_EVENT_TYPES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "EventDeviceClass", "EventEntity", "EventEntityDescription", diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 9e88c9bb0310cd..ca032643c9d218 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -32,7 +32,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "The entity_id of the Evohome zone." + "description": "The entity ID of the Evohome zone." }, "setpoint": { "name": "Setpoint", @@ -49,8 +49,8 @@ "description": "Sets a zone to follow its schedule.", "fields": { "entity_id": { - "name": "Entity", - "description": "The entity_id of the zone." + "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]", + "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]" } } } diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0fbc5cc6a6838e..73c0924422228f 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,7 +8,7 @@ from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,7 +57,9 @@ def __init__( ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data[CONF_PASSWORD] if camera is not None else None + camera.data[CONF_PASSWORD] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 58ac9dfde090b4..f1653661cddb09 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -29,7 +29,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Enter credentials to reauthenticate to ezviz cloud account", + "description": "Enter credentials to reauthenticate to EZVIZ cloud account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -45,7 +45,7 @@ "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -53,7 +53,7 @@ "step": { "init": { "data": { - "timeout": "Request Timeout (seconds)", + "timeout": "Request timeout (seconds)", "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" } } @@ -185,22 +185,22 @@ }, "services": { "set_alarm_detection_sensibility": { - "name": "Detection sensitivity", - "description": "Sets the detection sensibility level.", + "name": "Set detection sensibility", + "description": "Changes the sensibility level of the motion detection.", "fields": { "level": { - "name": "Sensitivity level", - "description": "Sensibility level (1-6) for type 0 (Normal camera) or (1-100) for type 3 (PIR sensor camera)." + "name": "Level", + "description": "Sensibility level. 1-6 for type 0 (normal camera), or 1-100 for type 3 (PIR sensor camera)." }, "type_value": { - "name": "Detection type", - "description": "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera." + "name": "Type", + "description": "Detection type. 0 for normal camera, or 3 for PIR sensor camera." } } }, "wake_device": { "name": "Wake camera", - "description": "This can be used to wake the camera/device from hibernation." + "description": "Wakes a camera from sleep mode. Especially useful for battery cameras." } } } diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index 3aee25995a98fb..3096590f47a072 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], - "requirements": ["PyFlick==1.1.2"] + "requirements": ["PyFlick==1.1.3"] } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 147d00c943d5f1..73b6f8793fb381 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -51,19 +51,19 @@ def native_value(self) -> Decimal: _LOGGER.warning( "Unexpected quantity for unit price: %s", self.coordinator.data ) - return self.coordinator.data.cost + return self.coordinator.data.cost * 100 @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - components: dict[str, Decimal] = {} + components: dict[str, float] = {} for component in self.coordinator.data.components: if component.charge_setter not in ATTR_COMPONENTS: _LOGGER.warning("Found unknown component: %s", component.charge_setter) continue - components[component.charge_setter] = component.value + components[component.charge_setter] = float(component.value * 100) return { ATTR_START_AT: self.coordinator.data.start_at, diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 5f3021960b5d8c..acdb8e35fe06a0 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -8,7 +8,7 @@ "step": { "user": { "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", - "title": "Connect to your Flume Account", + "title": "Connect to your Flume account", "data": { "username": "[%key:common::config_flow::data::username%]", "client_secret": "Client Secret", @@ -18,7 +18,7 @@ }, "reauth_confirm": { "description": "The password for {username} is no longer valid.", - "title": "Reauthenticate your Flume Account", + "title": "Reauthenticate your Flume account", "data": { "password": "[%key:common::config_flow::data::password%]" } @@ -65,11 +65,11 @@ "services": { "list_notifications": { "name": "List notifications", - "description": "Return user notifications.", + "description": "Returns a list of fetched user notifications.", "fields": { "config_entry": { "name": "Flume", - "description": "The flume config entry for which to return notifications." + "description": "The Flume config entry for which to return notifications." } } } diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 1472dfa4bf1f9b..7597a7c9c9aa2a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -133,7 +133,7 @@ def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: and mac_matches_by_one(entity_mac, unique_id) ): # Old format {dhcp_mac}....., New format {discovery_mac}.... - new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}" + new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id) :]}" else: return None _LOGGER.debug( diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 9a02120f33adac..035be5b115c512 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -16,7 +16,6 @@ from flux_led.scanner import FluxLEDDiscovery import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -30,6 +29,7 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import async_wifi_bulb_for_host @@ -78,7 +78,7 @@ def async_get_options_flow( return FluxLedOptionsFlow() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = FluxLEDDiscovery( diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index aa56708c645bbc..8f4517ff722932 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -101,7 +101,7 @@ }, "speed_pct": { "name": "Speed", - "description": "Effect speed for the custom effect (0-100)." + "description": "The speed of the effect in % (0-100, default 50)." }, "transition": { "name": "Transition", diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 5fb9f08f1c0098..b2b2d498f60b5e 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,7 +7,6 @@ from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_LIBRESPOT_JAVA_PORT, @@ -164,7 +164,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered forked-daapd device.""" version_num = 0 diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 88e2165defd78d..62a1cd14b3df43 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -6,9 +6,9 @@ from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .router import get_api, get_hosts_list_if_supported @@ -99,7 +99,7 @@ async def async_step_link( return self.async_show_form(step_id="link", errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Initialize flow from zeroconf.""" zeroconf_properties = discovery_info.properties diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 46422cee1055b7..0cfe37c7a3198e 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.2.1"], + "requirements": ["freebox-api==1.2.2"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 920ecda1c52d33..7b6057b3ba228d 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -13,7 +13,6 @@ from fritzconnection.core.exceptions import FritzConnectionException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -32,6 +31,12 @@ CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -150,7 +155,7 @@ def _determine_port(self, user_input: dict[str, Any]) -> int: return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") @@ -160,14 +165,13 @@ async def async_step_ssdp( self._host = host self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp[ATTR_UPNP_MODEL_NAME] ) uuid: str | None - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 90bd6068ecb13e..52bff67c22943a 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -214,6 +214,18 @@ async def async_setup( self._options = options await self.hass.async_add_executor_job(self.setup) + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + configuration_url=f"http://{self.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="AVM", + model=self.model, + name=self.config_entry.title, + sw_version=self.current_firmware, + ) + def setup(self) -> None: """Set up FritzboxTools class.""" @@ -429,7 +441,7 @@ async def _async_update_hosts_info(self) -> dict[str, Device]: hosts_info = await self.hass.async_add_executor_job( self.fritz_hosts.get_hosts_info ) - except Exception as ex: # noqa: BLE001 + except Exception as ex: if not self.hass.is_stopping: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 45665c786d46d6..33eb60d72cf30f 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -68,23 +68,14 @@ def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None: """Init device info class.""" self._avm_wrapper = avm_wrapper self._device_name = device_name - - @property - def mac_address(self) -> str: - """Return the mac address of the main device.""" - return self._avm_wrapper.mac + self.mac_address = self._avm_wrapper.mac @property def device_info(self) -> DeviceInfo: """Return the device information.""" return DeviceInfo( - configuration_url=f"http://{self._avm_wrapper.host}", connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self._avm_wrapper.unique_id)}, - manufacturer="AVM", - model=self._avm_wrapper.model, - name=self._device_name, - sw_version=self._avm_wrapper.current_firmware, ) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index ffec4a9ea29de3..3f66b43cc0c1a8 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -11,9 +11,13 @@ from requests.exceptions import HTTPError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -109,7 +113,7 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname @@ -121,9 +125,8 @@ async def async_step_ssdp( ): return self.async_abort(reason="ignore_ip6_link_local") - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: host}) @@ -138,7 +141,7 @@ async def async_step_ssdp( self.hass.config_entries.async_update_entry(entry, unique_id=uuid) return self.async_abort(reason="already_configured") - self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host) + self._name = str(discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or host) self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index ccc15d8040167e..f35c9ce5bc1663 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -9,12 +9,12 @@ from pyfronius import Fronius, FroniusError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, FroniusConfigEntryData diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 03f666ffafde6d..c6c3ff4b6028d9 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -794,7 +794,7 @@ def __init__( "unit" ) self._attr_unique_id = ( - f'{logger_data["unique_identifier"]["value"]}-{description.key}' + f"{logger_data['unique_identifier']['value']}-{description.key}" ) @@ -815,7 +815,7 @@ def __init__( if (meter_uid := meter_data["serial"]["value"]) == "n.a.": meter_uid = ( f"{coordinator.solar_net.solar_net_device_id}:" - f'{meter_data["model"]["value"]}' + f"{meter_data['model']['value']}" ) self._attr_device_info = DeviceInfo( @@ -849,7 +849,7 @@ def __init__( sw_version=device_data["software"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{device_data['serial']['value']}-{description.key}" class PowerFlowSensor(_FroniusSensorEntity): @@ -883,7 +883,7 @@ def __init__( super().__init__(coordinator, description, solar_net_id) storage_data = self._device_data() - self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{storage_data['serial']['value']}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, storage_data["serial"]["value"])}, manufacturer=storage_data["manufacturer"]["value"], diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2094f817dcd710..3d9f12bd3d3fa3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250103.0"] + "requirements": ["home-assistant-frontend==20250109.0"] } diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py index 0b51cb767c7bc8..9bad880a9b32fa 100644 --- a/homeassistant/components/frontier_silicon/browse_media.py +++ b/homeassistant/components/frontier_silicon/browse_media.py @@ -38,7 +38,9 @@ def _item_preset_payload(preset: Preset, player_mode: str) -> BrowseMedia: media_content_type=MediaType.CHANNEL, # We add 1 to the preset key to keep it in sync with the numbering shown # on the interface of the device - media_content_id=f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key)+1}", + media_content_id=( + f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key) + 1}" + ), can_play=True, can_expand=False, ) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0612419fc335b2..f6514da28ffb58 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -15,9 +15,9 @@ ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_WEBFSAPI_URL, @@ -87,7 +87,7 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Process entity discovered via SSDP.""" diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index f25e01bcd1172d..547545e4feb4f5 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -15,7 +15,7 @@ from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .coordinator import FGLairCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 5359075c7280f7..c0f5ab7dce4e1c 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -25,13 +25,11 @@ ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FGLairConfigEntry -from .const import DOMAIN from .coordinator import FGLairCoordinator +from .entity import FGLairEntity HA_TO_FUJI_FAN = { FAN_LOW: FanSpeed.LOW, @@ -72,28 +70,19 @@ async def async_setup_entry( ) -class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): +class FGLairDevice(FGLairEntity, ClimateEntity): """Represent a Fujitsu HVAC device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_HALVES _attr_target_temperature_step = 0.5 - _attr_has_entity_name = True _attr_name = None def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: """Store the representation of the device and set the static attributes.""" - super().__init__(coordinator, context=device.device_serial_number) + super().__init__(coordinator, device) self._attr_unique_id = device.device_serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_serial_number)}, - name=device.device_name, - manufacturer="Fujitsu", - model=device.property_values["model_name"], - serial_number=device.device_serial_number, - sw_version=device.property_values["mcu_firmware_version"], - ) self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -109,11 +98,6 @@ def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._set_attr() - @property - def device(self) -> FujitsuHVAC: - """Return the device object from the coordinator data.""" - return self.coordinator.data[self.coordinator_context] - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py index eac3cfd6ce5908..d98464e4751fd3 100644 --- a/homeassistant/components/fujitsu_fglair/coordinator.py +++ b/homeassistant/components/fujitsu_fglair/coordinator.py @@ -48,10 +48,16 @@ async def _async_update_data(self) -> dict[str, FujitsuHVAC]: raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e if not listening_entities: - devices = [dev for dev in devices if isinstance(dev, FujitsuHVAC)] + devices = [ + dev + for dev in devices + if isinstance(dev, FujitsuHVAC) and dev.is_online() + ] else: devices = [ - dev for dev in devices if dev.device_serial_number in listening_entities + dev + for dev in devices + if dev.device_serial_number in listening_entities and dev.is_online() ] try: diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py new file mode 100644 index 00000000000000..5c41a8ab18edcc --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/entity.py @@ -0,0 +1,38 @@ +"""Fujitsu FGlair base entity.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FGLairCoordinator + + +class FGLairEntity(CoordinatorEntity[FGLairCoordinator]): + """Generic Fglair entity (base class).""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, context=device.device_serial_number) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_serial_number)}, + name=device.device_name, + manufacturer="Fujitsu", + model=device.property_values["model_name"], + serial_number=device.device_serial_number, + sw_version=device.property_values["mcu_firmware_version"], + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator_context in self.coordinator.data + + @property + def device(self) -> FujitsuHVAC: + """Return the device object from the coordinator data.""" + return self.coordinator.data[self.coordinator_context] diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py new file mode 100644 index 00000000000000..1426e2349ea74c --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -0,0 +1,47 @@ +"""Outside temperature sensor for Fujitsu FGlair HVAC systems.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .climate import FGLairConfigEntry +from .coordinator import FGLairCoordinator +from .entity import FGLairEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FGLairConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up one Fujitsu HVAC device.""" + async_add_entities( + FGLairOutsideTemperature(entry.runtime_data, device) + for device in entry.runtime_data.data.values() + ) + + +class FGLairOutsideTemperature(FGLairEntity, SensorEntity): + """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_translation_key = "fglair_outside_temp" + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, device) + self._attr_unique_id = f"{device.device_serial_number}_outside_temperature" + + @property + def native_value(self) -> float | None: + """Return the sensed outdoor temperature un celsius.""" + return self.device.outdoor_temperature # type: ignore[no-any-return] diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json index 3ad4e59ec1cbe7..ea97ca416e5450 100644 --- a/homeassistant/components/fujitsu_fglair/strings.json +++ b/homeassistant/components/fujitsu_fglair/strings.json @@ -35,5 +35,12 @@ "cn": "China" } } + }, + "entity": { + "sensor": { + "fglair_outside_temp": { + "name": "Outside temperature" + } + } } } diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 15771d12b5dcc2..53185e8ab76691 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -11,7 +11,6 @@ from fullykiosk.exceptions import FullyKioskError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from .const import DEFAULT_PORT, DOMAIN, LOGGER diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 1969ebfffe9cb7..77724e3f673cd6 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py new file mode 100644 index 00000000000000..bcef609d01aa3e --- /dev/null +++ b/homeassistant/components/fyta/binary_sensor.py @@ -0,0 +1,117 @@ +"""Binary sensors for Fyta.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from fyta_cli.fyta_models import Plant + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FytaConfigEntry +from .entity import FytaPlantEntity + + +@dataclass(frozen=True, kw_only=True) +class FytaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Fyta binary sensor entity.""" + + value_fn: Callable[[Plant], bool] + + +BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [ + FytaBinarySensorEntityDescription( + key="low_battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.low_battery, + ), + FytaBinarySensorEntityDescription( + key="notification_light", + translation_key="notification_light", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_light, + ), + FytaBinarySensorEntityDescription( + key="notification_nutrition", + translation_key="notification_nutrition", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_nutrition, + ), + FytaBinarySensorEntityDescription( + key="notification_temperature", + translation_key="notification_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_temperature, + ), + FytaBinarySensorEntityDescription( + key="notification_water", + translation_key="notification_water", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_water, + ), + FytaBinarySensorEntityDescription( + key="sensor_update_available", + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.sensor_update_available, + ), + FytaBinarySensorEntityDescription( + key="productive_plant", + translation_key="productive_plant", + value_fn=lambda plant: plant.productive_plant, + ), + FytaBinarySensorEntityDescription( + key="repotted", + translation_key="repotted", + value_fn=lambda plant: plant.repotted, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA binary sensors.""" + coordinator = entry.runtime_data + + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantBinarySensor(FytaPlantEntity, BinarySensorEntity): + """Represents a Fyta binary sensor.""" + + entity_description: FytaBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return value of the binary sensor.""" + + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 4c078098ec1301..0d0ec533c44d39 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -2,8 +2,8 @@ from fyta_cli.fyta_models import Plant -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FytaConfigEntry @@ -20,7 +20,7 @@ def __init__( self, coordinator: FytaCoordinator, entry: FytaConfigEntry, - description: SensorEntityDescription, + description: EntityDescription, plant_id: int, ) -> None: """Initialize the Fyta sensor.""" diff --git a/homeassistant/components/fyta/icons.json b/homeassistant/components/fyta/icons.json index b96eeb15e62535..5b6380196f4c34 100644 --- a/homeassistant/components/fyta/icons.json +++ b/homeassistant/components/fyta/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "notification_light": { + "default": "mdi:lightbulb-alert-outline" + }, + "notification_nutrition": { + "default": "mdi:beaker-alert-outline" + }, + "notification_temperature": { + "default": "mdi:thermometer-alert" + }, + "notification_water": { + "default": "mdi:watering-can-outline" + }, + "productive_plant": { + "default": "mdi:fruit-grapes" + }, + "repotted": { + "default": "mdi:shovel" + } + }, "sensor": { "status": { "default": "mdi:flower" @@ -13,6 +33,9 @@ "moisture_status": { "default": "mdi:water-percent-alert" }, + "nutrients_status": { + "default": "mdi:emoticon-poop" + }, "salinity_status": { "default": "mdi:sprout-outline" }, @@ -21,6 +44,12 @@ }, "salinity": { "default": "mdi:sprout-outline" + }, + "last_fertilised": { + "default": "mdi:calendar-check" + }, + "next_fertilisation": { + "default": "mdi:calendar-end" } } } diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 89ee22265cf9b2..254e4522819035 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -82,6 +82,13 @@ class FytaSensorEntityDescription(SensorEntityDescription): options=PLANT_MEASUREMENT_STATUS_LIST, value_fn=lambda plant: plant.moisture_status.name.lower(), ), + FytaSensorEntityDescription( + key="nutrients_status", + translation_key="nutrients_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=lambda plant: plant.nutrients_status.name.lower(), + ), FytaSensorEntityDescription( key="salinity_status", translation_key="salinity_status", @@ -124,6 +131,18 @@ class FytaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda plant: plant.ph, ), + FytaSensorEntityDescription( + key="fertilise_last", + translation_key="last_fertilised", + device_class=SensorDeviceClass.DATE, + value_fn=lambda plant: plant.fertilise_last, + ), + FytaSensorEntityDescription( + key="fertilise_next", + translation_key="next_fertilisation", + device_class=SensorDeviceClass.DATE, + value_fn=lambda plant: plant.fertilise_next, + ), FytaSensorEntityDescription( key="battery_level", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index fc9f424d5aae9c..1a25f654e19e6a 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -38,6 +38,29 @@ } }, "entity": { + "binary_sensor": { + "notification_light": { + "name": "Light notification" + }, + "notification_nutrition": { + "name": "Nutrition notification" + }, + "notification_temperature": { + "name": "Temperature notification" + }, + "notification_water": { + "name": "Water notification" + }, + "productive_plant": { + "name": "Productive plant" + }, + "repotted": { + "name": "Repotted" + }, + "sensor_update_available": { + "name": "Sensor update available" + } + }, "sensor": { "scientific_name": { "name": "Scientific name" @@ -84,6 +107,17 @@ "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, + "nutrients_status": { + "name": "Nutrients state", + "state": { + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" + } + }, "salinity_status": { "name": "Salinity state", "state": { @@ -100,6 +134,12 @@ }, "salinity": { "name": "Salinity" + }, + "last_fertilised": { + "name": "Last fertilized" + }, + "next_fertilisation": { + "name": "Next fertilization" } } }, diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index b106f9907bb432..18f50593dcaeb0 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -78,7 +78,7 @@ async def async_step_local_api( errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -113,7 +113,7 @@ async def async_step_cloud_api( errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index faf5011d7526aa..42d53c7fa00a0b 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -37,11 +37,11 @@ "services": { "set_zone_mode": { "name": "Set zone mode", - "description": "Set the zone to an operating mode.", + "description": "Sets the zone to an operating mode.", "fields": { "entity_id": { "name": "Entity", - "description": "The zone's entity_id." + "description": "The zone's entity ID." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", @@ -51,7 +51,7 @@ }, "set_zone_override": { "name": "Set zone override", - "description": "Overrides the zone's set point for a given duration.", + "description": "Overrides the zone's setpoint for a given duration.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 96244e08d1b838..5f0d6e92ee188e 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -83,13 +83,8 @@ def state_change_listener(event: Event[EventStateChangedData]) -> None: ) to_match = condition.zone(hass, zone_state, to_state) if to_state else False - if ( - trigger_event == EVENT_ENTER - and not from_match - and to_match - or trigger_event == EVENT_LEAVE - and from_match - and not to_match + if (trigger_event == EVENT_ENTER and not from_match and to_match) or ( + trigger_event == EVENT_LEAVE and from_match and not to_match ): hass.async_run_hass_job( job, diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 59eba69d60a6e6..0741926296ee82 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -375,6 +375,8 @@ def _update_data_valid(self) -> None: self._data_valid = self._attr_native_value is not None and ( not self._numeric_state_expected or isinstance(self._attr_native_value, (int, float)) - or isinstance(self._attr_native_value, str) - and self._attr_native_value.isnumeric() + or ( + isinstance(self._attr_native_value, str) + and self._attr_native_value.isnumeric() + ) ) diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 3c1c84c42b567d..234411936cb321 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.7" +RECOMMENDED_VERSION = "1.9.8" diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index dabe642b6589f1..9764d36e42c0ea 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -8,11 +8,11 @@ from goalzero import Yeti, exceptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER @@ -27,7 +27,7 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN): _discovered_ip: str async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 837c04547196ff..0348d0b428ce94 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -10,7 +10,6 @@ from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_DEVICE, @@ -19,6 +18,11 @@ CONF_USERNAME, ) from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN @@ -40,16 +44,14 @@ def __init__(self) -> None: self._device_type: str | None = None async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) return await self._async_discovery_handler(discovery_info.host) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index acc69c3799ae86..5ee0cdd9c146ce 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -13,20 +13,22 @@ } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 76869487ee3e05..4309a99c0ca53a 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -521,7 +521,7 @@ def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - __slots__ = ("hass", "config", "state", "entity_id", "_traits") + __slots__ = ("_traits", "config", "entity_id", "hass", "state") def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 4fd817aadce6b8..87c930239003b4 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -4,27 +4,27 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account" - }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Assistant SDK integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 99b7dadbb0e51d..ebca586d1a3e18 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -114,9 +114,9 @@ async def async_process_audio_stream( ) ) - async def request_generator() -> ( - AsyncGenerator[speech_v1.StreamingRecognizeRequest] - ): + async def request_generator() -> AsyncGenerator[ + speech_v1.StreamingRecognizeRequest + ]: # The first request must only contain a streaming_config yield speech_v1.StreamingRecognizeRequest(streaming_config=streaming_config) # All subsequent requests must only contain audio_content diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 485d640a04dbf3..3e455f645ad02e 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -49,10 +49,8 @@ async def check_and_refresh_token(self) -> str: "OAuth session is not valid, reauth required" ) from ex raise ConfigEntryNotReady from ex - if ( - isinstance(ex, RefreshError) - or hasattr(ex, "status") - and ex.status == 400 + if isinstance(ex, RefreshError) or ( + hasattr(ex, "status") and ex.status == 400 ): self.oauth_session.config_entry.async_start_reauth( self.oauth_session.hass diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index f93a8581e1ce18..759242593ff82d 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -13,19 +13,19 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {email}.", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with {email}." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index fa3f4669dac602..5695192dd27b4e 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Photos integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -66,11 +74,11 @@ "services": { "upload": { "name": "Upload media", - "description": "Upload images or videos to Google Photos.", + "description": "Uploads images or videos to Google Photos.", "fields": { "config_entry_id": { - "name": "Integration Id", - "description": "The Google Photos integration id." + "name": "Integration ID", + "description": "The Google Photos integration ID." }, "filename": { "name": "Filename", diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index d8cb06d9bcd30b..406c4440d003e8 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -4,27 +4,29 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { "title": "Link Google Account" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Sheets integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index a26cf8c58ec6ae..b58678f6d30005 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Tasks integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index ed9709d2811baf..ab0291bc58ff79 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -88,6 +88,7 @@ "uk", "ur", "vi", + "yue", # dialects "zh-CN", "zh-cn", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 7074d0ed444198..b5b1f670675424 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], - "requirements": ["gTTS==2.2.4"] + "requirements": ["gTTS==2.5.3"] } diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index bd92093c29c4bc..7b7a1fb5a507d8 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -39,6 +39,10 @@ key=GoveeBLEBinarySensorDeviceClass.OCCUPANCY, device_class=BinarySensorDeviceClass.OCCUPANCY, ), + GoveeBLEBinarySensorDeviceClass.PRESENCE: BinarySensorEntityDescription( + key=GoveeBLEBinarySensorDeviceClass.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + ), } diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py index 55275477164b94..5e5aa6354bed97 100644 --- a/homeassistant/components/govee_ble/event.py +++ b/homeassistant/components/govee_ble/event.py @@ -102,8 +102,7 @@ async def async_setup_entry( descriptions = [MOTION_DESCRIPTION] elif sensor_type is SensorType.VIBRATION: descriptions = [VIBRATION_DESCRIPTION] - elif sensor_type is SensorType.BUTTON: - button_count = model_info.button_count + elif button_count := model_info.button_count: descriptions = BUTTON_DESCRIPTIONS[0:button_count] else: return diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 39a66ad36a796f..5a123de706607b 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -42,6 +42,10 @@ "local_name": "GVH5127*", "connectable": false }, + { + "local_name": "GVH5130*", + "connectable": false + }, { "manufacturer_id": 1, "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", @@ -127,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.40.0"] + "requirements": ["govee-ble==0.42.0"] } diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 03a8be4bed5960..40db70a2eb35a4 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -440,10 +440,8 @@ def _async_update_group_state(self, tr_state: State | None = None) -> None: if not self._on_off: return - if ( - tr_state is None - or self._assumed_state - and not tr_state.attributes.get(ATTR_ASSUMED_STATE) + if tr_state is None or ( + self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE) ): self._assumed_state = self.mode(self._assumed.values()) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index cf694af0d98ad1..fb90eb9b22cb2d 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -238,7 +238,7 @@ }, "set": { "name": "Set", - "description": "Creates/Updates a user group.", + "description": "Creates/Updates a group.", "fields": { "object_id": { "name": "Object ID", diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index fbc65050704126..f9e9c31ce46fd5 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,7 +342,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ # noqa: S608 + """ result = schedule.engine.connect().execute( text(sql_query), { diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index c4146d72469b23..55e4893e31be87 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -8,10 +8,11 @@ from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_UID, DOMAIN, LOGGER @@ -101,7 +102,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { @@ -114,7 +115,7 @@ async def async_step_dhcp( return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 9a9d689bedcab2..1972e89c58af20 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -2,7 +2,6 @@ from habiticalib import Habitica -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -61,6 +60,6 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index bf42348e2b829c..5e3040e0606242 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -19,6 +19,8 @@ from .entity import HabiticaBase from .types import HabiticaConfigEntry +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 14625b31c2b8de..450a5cdcf204bd 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -335,16 +335,24 @@ async def async_press(self) -> None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="service_call_unallowed", ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 46191acf270665..f33f3c3c12f618 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -3,13 +3,14 @@ from __future__ import annotations from abc import abstractmethod +from dataclasses import asdict from datetime import date, datetime, timedelta from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from uuid import UUID from dateutil.rrule import rrule -from habiticalib import TaskType +from habiticalib import Frequency, TaskType from homeassistant.components.calendar import ( CalendarEntity, @@ -25,6 +26,8 @@ from .entity import HabiticaBase from .util import build_rrule, get_recurrence_rule +PARALLEL_UPDATES = 1 + class HabiticaCalendar(StrEnum): """Habitica calendars.""" @@ -92,9 +95,11 @@ def get_recurrence_dates( ) -> list[datetime]: """Calculate recurrence dates based on start_date and end_date.""" if end_date: - return recurrences.between( + recurrence_dates = recurrences.between( start_date, end_date - timedelta(days=1), inc=True ) + + return cast(list[datetime], recurrence_dates) # if no end_date is given, return only the next recurrence return [recurrences.after(start_date, inc=True)] @@ -193,6 +198,10 @@ def get_events( # only dailies that that are not 'grey dailies' if not (task.Type is TaskType.DAILY and task.everyX): continue + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): + continue recurrences = build_rrule(task) recurrence_dates = self.get_recurrence_dates( @@ -334,6 +343,11 @@ def get_events( if not (task.Type is TaskType.DAILY and task.everyX): continue + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): + continue + recurrences = build_rrule(task) recurrences_start = self.start_of_today diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 0c7ce1fdfdbcc1..7a7f369cb09d92 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -40,6 +40,7 @@ DOMAIN, FORGOT_PASSWORD_URL, HABITICANS_URL, + SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, SIGN_UP_URL, @@ -105,6 +106,21 @@ } ) +STEP_RECONF_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(SECTION_DANGER_ZONE): data_entry_flow.section( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_VERIFY_SSL): bool, + }, + ), + {"collapsed": True}, + ), + } +) + _LOGGER = logging.getLogger(__name__) @@ -260,6 +276,50 @@ async def async_step_reauth_confirm( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + suggested_values = { + CONF_API_KEY: reconf_entry.data[CONF_API_KEY], + SECTION_DANGER_ZONE: { + CONF_URL: reconf_entry.data[CONF_URL], + CONF_VERIFY_SSL: reconf_entry.data.get(CONF_VERIFY_SSL, True), + }, + } + + if user_input: + errors, user = await self.validate_api_key( + { + **reconf_entry.data, + **user_input, + **user_input[SECTION_DANGER_ZONE], + } + ) + if not errors and user is not None: + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + **user_input[SECTION_DANGER_ZONE], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_RECONF_DATA_SCHEMA, + suggested_values=user_input or suggested_values, + ), + errors=errors, + description_placeholders={ + "site_data": SITE_DATA_URL, + "habiticans": HABITICANS_URL, + }, + ) + async def validate_login( self, user_input: Mapping[str, Any] ) -> tuple[dict[str, str], LoginData | None, UserData | None]: diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 47191e927756f2..5eb616142e5fb6 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,3 +56,4 @@ SECTION_REAUTH_LOGIN = "reauth_login" SECTION_REAUTH_API_KEY = "reauth_api_key" +SECTION_DANGER_ZONE = "danger_zone" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 587f8148398e03..f97b98410bbdca 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -85,11 +85,19 @@ async def _async_setup(self) -> None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e if not self.config_entry.data.get(CONF_NAME): @@ -108,8 +116,18 @@ async def _async_update_data(self) -> HabiticaData: except TooManyRequestsError: _LOGGER.debug("Rate limit exceeded, will try again later") return self.data - except (HabiticaException, ClientError) as e: - raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e else: return HabiticaData(user=user, tasks=tasks + completed_todos) @@ -124,11 +142,19 @@ async def execute( raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, ) from e - except (HabiticaException, ClientError) as e: + except ClientError as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.async_request_refresh() diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index b74600a2789c17..6ae6ebd728b894 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -138,6 +138,27 @@ }, "constitution": { "default": "mdi:run-fast" + }, + "food_total": { + "default": "mdi:candy", + "state": { + "0": "mdi:candy-off" + } + }, + "eggs_total": { + "default": "mdi:egg", + "state": { + "0": "mdi:egg-off" + } + }, + "hatching_potions_total": { + "default": "mdi:flask-round-bottom" + }, + "saddle": { + "default": "mdi:horse" + }, + "quest_scrolls": { + "default": "mdi:script-text-outline" } }, "switch": { diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 27b406c475c6d8..f1dbbc64d41ebb 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -16,6 +16,8 @@ from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase +PARALLEL_UPDATES = 1 + class HabiticaImageEntity(StrEnum): """Image entities.""" diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a1c1ae7787b816..1c92c314e66703 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.2"] + "requirements": ["habiticalib==0.3.3"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index f1023e3d0dcb1c..9eadba496f27a6 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -4,11 +4,9 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: test already_configured, tests should finish with create_entry or abort, assert unique_id + config-flow-test-coverage: done config-flow: done - dependency-transparency: todo + dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done @@ -33,7 +31,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -66,11 +64,9 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: todo - comment: translations for UpdateFailed missing + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: done comment: Used to inform of deprecated entities and actions. @@ -79,6 +75,6 @@ rules: comment: Not applicable. Only one device per config entry. Removed together with the config entry. # Platinum - async-dependency: todo + async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 60dbf0d99b05b4..57c391f5c1218e 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -15,24 +15,45 @@ TaskType, UserData, deserialize_task, + ha, ) +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import StateType -from .const import ASSETS_URL +from .const import ASSETS_URL, DOMAIN +from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import get_attribute_points, get_attributes_total +from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) +SVG_CLASS = { + HabiticaClass.WARRIOR: ha.WARRIOR, + HabiticaClass.ROGUE: ha.ROGUE, + HabiticaClass.MAGE: ha.WIZARD, + HabiticaClass.HEALER: ha.HEALER, +} + + +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaSensorEntityDescription(SensorEntityDescription): @@ -73,6 +94,11 @@ class HabiticaSensorEntity(StrEnum): INTELLIGENCE = "intelligence" CONSTITUTION = "constitution" PERCEPTION = "perception" + EGGS_TOTAL = "eggs_total" + HATCHING_POTIONS_TOTAL = "hatching_potions_total" + FOOD_TOTAL = "food_total" + SADDLE = "saddle" + QUEST_SCROLLS = "quest_scrolls" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -86,6 +112,7 @@ class HabiticaSensorEntity(StrEnum): translation_key=HabiticaSensorEntity.HEALTH, suggested_display_precision=0, value_fn=lambda user, _: user.stats.hp, + entity_picture=ha.HP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HEALTH_MAX, @@ -98,21 +125,25 @@ class HabiticaSensorEntity(StrEnum): translation_key=HabiticaSensorEntity.MANA, suggested_display_precision=0, value_fn=lambda user, _: user.stats.mp, + entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA_MAX, translation_key=HabiticaSensorEntity.MANA_MAX, value_fn=lambda user, _: user.stats.maxMP, + entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE, translation_key=HabiticaSensorEntity.EXPERIENCE, value_fn=lambda user, _: user.stats.exp, + entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE_MAX, translation_key=HabiticaSensorEntity.EXPERIENCE_MAX, value_fn=lambda user, _: user.stats.toNextLevel, + entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.LEVEL, @@ -124,6 +155,7 @@ class HabiticaSensorEntity(StrEnum): translation_key=HabiticaSensorEntity.GOLD, suggested_display_precision=2, value_fn=lambda user, _: user.stats.gp, + entity_picture=ha.GP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.CLASS, @@ -135,14 +167,14 @@ class HabiticaSensorEntity(StrEnum): HabiticaSensorEntityDescription( key=HabiticaSensorEntity.GEMS, translation_key=HabiticaSensorEntity.GEMS, - value_fn=lambda user, _: round(user.balance * 4) if user.balance else None, + value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), suggested_display_precision=0, entity_picture="shop_gem.png", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.TRINKETS, translation_key=HabiticaSensorEntity.TRINKETS, - value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0, + value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, suggested_display_precision=0, native_unit_of_measurement="⧖", entity_picture="notif_subscriber_reward.png", @@ -179,6 +211,44 @@ class HabiticaSensorEntity(StrEnum): suggested_display_precision=0, native_unit_of_measurement="CON", ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EGGS_TOTAL, + translation_key=HabiticaSensorEntity.EGGS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.eggs.values()), + entity_picture="Pet_Egg_Egg.png", + attributes_fn=lambda user, content: inventory_list(user, content, "eggs"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()), + entity_picture="Pet_HatchingPotion_RoyalPurple.png", + attributes_fn=( + lambda user, content: inventory_list(user, content, "hatchingPotions") + ), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.FOOD_TOTAL, + translation_key=HabiticaSensorEntity.FOOD_TOTAL, + value_fn=( + lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") + ), + entity_picture=ha.FOOD, + attributes_fn=lambda user, content: inventory_list(user, content, "food"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.SADDLE, + translation_key=HabiticaSensorEntity.SADDLE, + value_fn=lambda user, _: user.items.food.get("Saddle", 0), + entity_picture="Pet_Food_Saddle.png", + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.QUEST_SCROLLS, + translation_key=HabiticaSensorEntity.QUEST_SCROLLS, + value_fn=(lambda user, _: sum(n for n in user.items.quests.values())), + entity_picture="inventory_quest_scroll_dustbunnies.png", + attributes_fn=lambda user, content: inventory_list(user, content, "quests"), + ), ) @@ -226,6 +296,13 @@ class HabiticaSensorEntity(StrEnum): ) +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in + + async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -234,14 +311,58 @@ async def async_setup_entry( """Set up the habitica sensors.""" coordinator = config_entry.runtime_data + ent_reg = er.async_get(hass) + entities: list[SensorEntity] = [] + description: SensorEntityDescription + + def add_deprecated_entity( + description: SensorEntityDescription, + entity_cls: Callable[ + [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity + ], + ) -> None: + """Add deprecated entities.""" + if entity_id := ent_reg.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{config_entry.unique_id}_{description.key}", + ): + entity_entry = ent_reg.async_get(entity_id) + if entity_entry and entity_entry.disabled: + ent_reg.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + ) + elif entity_entry: + entities.append(entity_cls(coordinator, description)) + if entity_used_in(hass, entity_id): + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "name": str( + entity_entry.name or entity_entry.original_name + ), + "entity": entity_id, + }, + ) + + for description in SENSOR_DESCRIPTIONS: + if description.key is HabiticaSensorEntity.HEALTH_MAX: + add_deprecated_entity(description, HabiticaSensor) + else: + entities.append(HabiticaSensor(coordinator, description)) + + for description in TASK_SENSOR_DESCRIPTION: + add_deprecated_entity(description, HabiticaTaskSensor) - entities: list[SensorEntity] = [ - HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS - ] - entities.extend( - HabiticaTaskSensor(coordinator, description) - for description in TASK_SENSOR_DESCRIPTION - ) async_add_entities(entities, True) @@ -268,8 +389,18 @@ def extra_state_attributes(self) -> dict[str, float | None] | None: @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" + if self.entity_description.key is HabiticaSensorEntity.CLASS and ( + _class := self.coordinator.data.user.stats.Class + ): + return SVG_CLASS[_class] + if entity_picture := self.entity_description.entity_picture: - return f"{ASSETS_URL}{entity_picture}" + return ( + entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + return None diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 5961c139003752..a28aada85facd7 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -224,6 +224,7 @@ async def cast_skill(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -243,10 +244,17 @@ async def cast_skill(call: ServiceCall) -> ServiceResponse: translation_key="skill_not_found", translation_placeholders={"skill": call.data[ATTR_SKILL]}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() @@ -274,6 +282,7 @@ async def manage_quests(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -283,9 +292,17 @@ async def manage_quests(call: ServiceCall) -> ServiceResponse: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="quest_not_found" ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_call_exception" + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: return asdict(response.data) @@ -335,6 +352,7 @@ async def score_task(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: if task_value is not None: @@ -349,11 +367,19 @@ async def score_task(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, ) from e - except (HabiticaException, ClientError) as e: + except ClientError as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() @@ -382,10 +408,17 @@ async def transformation(call: ServiceCall) -> ServiceResponse: translation_domain=DOMAIN, translation_key="party_not_found", ) from e - except (ClientError, HabiticaException) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e try: target_id = next( @@ -411,6 +444,7 @@ async def transformation(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -418,10 +452,17 @@ async def transformation(call: ServiceCall) -> ServiceResponse: translation_key="item_not_found", translation_placeholders={"item": call.data[ATTR_ITEM]}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: return asdict(response.data) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index b4925861d67444..4d353cec40e330 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -13,7 +13,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -25,8 +26,8 @@ "user": { "title": "Habitica - Gamify your life", "menu_options": { - "login": "Login to Habitica", - "advanced": "Login to other instances" + "login": "Log in to Habitica", + "advanced": "Log in to other instances" }, "description": "![Habiticans]({habiticans}) Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})" }, @@ -85,6 +86,30 @@ } } } + }, + "reconfigure": { + "title": "Update Habitica configuration", + "description": "![Habiticans]({habiticans})\n\nEnter your new API token below. You can find it in Habitica under [**Settings -> Site Data**]({site_data})", + "data": { + "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]" + }, + "sections": { + "danger_zone": { + "name": "Critical configuration options", + "description": "These settings impact core functionality. Modifications are unnecessary if connected to the official Habitica instance and may disrupt the integration. Proceed with caution.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "URL of the Habitica instance", + "verify_ssl": "[%key:component::habitica::config::step::advanced::data_description::verify_ssl%]" + } + } + } } } }, @@ -310,6 +335,26 @@ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" } } + }, + "eggs_total": { + "name": "Eggs", + "unit_of_measurement": "eggs" + }, + "hatching_potions_total": { + "name": "Hatching potions", + "unit_of_measurement": "potions" + }, + "food_total": { + "name": "Pet food", + "unit_of_measurement": "foods" + }, + "saddle": { + "name": "Saddles", + "unit_of_measurement": "saddles" + }, + "quest_scrolls": { + "name": "Quest scrolls", + "unit_of_measurement": "scrolls" } }, "switch": { @@ -355,13 +400,13 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Rate limit exceeded, try again later" + "message": "Rate limit exceeded, try again in {retry_after} seconds" }, "service_call_unallowed": { "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { - "message": "Unable to connect to Habitica, try again later" + "message": "Unable to connect to Habitica: {reason}" }, "not_enough_mana": { "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." @@ -401,6 +446,10 @@ } }, "issues": { + "deprecated_entity": { + "title": "The Habitica {name} entity is deprecated", + "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." + }, "deprecated_api_call": { "title": "The Habitica action habitica.api_call is deprecated", "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index a14327f5378b8e..c1786059300145 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -3,11 +3,18 @@ from __future__ import annotations from enum import StrEnum +import logging from typing import TYPE_CHECKING from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaException, Task, TaskType +from habiticalib import ( + Direction, + HabiticaException, + Task, + TaskType, + TooManyRequestsError, +) from homeassistant.components import persistent_notification from homeassistant.components.todo import ( @@ -17,7 +24,7 @@ TodoListEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,6 +35,8 @@ from .types import HabiticaConfigEntry from .util import next_due_date +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 @@ -72,7 +81,14 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS: try: await self.coordinator.habitica.delete_completed_todos() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key="delete_completed_todos_failed", @@ -81,7 +97,14 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: for task_id in uids: try: await self.coordinator.habitica.delete_task(UUID(task_id)) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"delete_{self.entity_description.key}_failed", @@ -108,7 +131,14 @@ async def async_move_todo_item( try: await self.coordinator.habitica.reorder_task(UUID(uid), pos) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"move_{self.entity_description.key}_item_failed", @@ -160,7 +190,14 @@ async def async_update_todo_item(self, item: TodoItem) -> None: try: await self.coordinator.habitica.update_task(UUID(item.uid), task) refresh_required = True + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"update_{self.entity_description.key}_item_failed", @@ -187,8 +224,14 @@ async def async_update_todo_item(self, item: TodoItem) -> None: refresh_required = True else: score_result = None - + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"score_{self.entity_description.key}_item_failed", @@ -260,7 +303,14 @@ async def async_create_todo_item(self, item: TodoItem) -> None: date=item.due, ) ) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"create_{self.entity_description.key}_item_failed", diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 4c1e54639d077f..757c675b04597d 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import fields +from dataclasses import asdict, fields import datetime from math import floor from typing import TYPE_CHECKING @@ -23,9 +23,6 @@ ) from habiticalib import ContentData, Frequency, TaskData, UserData -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -34,6 +31,8 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N if task.everyX == 0 or not task.nextDue: # grey dailies never become due return None + if task.frequency is Frequency.WEEKLY and not any(asdict(task.repeat).values()): + return None if TYPE_CHECKING: assert task.startDate @@ -57,13 +56,6 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in - - FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} @@ -159,3 +151,14 @@ def get_attributes_total(user: UserData, content: ContentData, attribute: str) - return floor( sum(value for value in get_attribute_points(user, content, attribute).values()) ) + + +def inventory_list( + user: UserData, content: ContentData, item_type: str +) -> dict[str, int]: + """List inventory items of given type.""" + return { + getattr(content, item_type)[k].text: v + for k, v in getattr(user.items, item_type, {}).items() + if k != "Saddle" + } diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index b75ad617b39133..b507c0ae112131 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -11,7 +11,6 @@ import aiohttp import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, @@ -26,6 +25,10 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID from .util import ( @@ -93,13 +96,13 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Harmony device.""" _LOGGER.debug("SSDP discovery_info: %s", discovery_info) parsed_url = urlparse(discovery_info.ssdp_location) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self._async_abort_entries_match({CONF_HOST: parsed_url.hostname}) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 41c55bfc855ac9..4dba412a17c4f1 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -124,8 +124,7 @@ async def connect(self) -> None: except (ValueError, AttributeError) as err: await self._client.close() raise ConfigEntryNotReady( - f"{self.name}: Error {err} while connected HUB at:" - f" {self._address}:8088" + f"{self.name}: Error {err} while connected HUB at: {self._address}:8088" ) from err if not connected: await self._client.close() diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index fec84737e782b1..b95f520b9e0e3b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -115,7 +115,7 @@ get_supervisor_info, # noqa: F401 get_supervisor_stats, # noqa: F401 ) -from .discovery import async_setup_discovery_view # noqa: F401 +from .discovery import async_setup_discovery_view from .handler import ( # noqa: F401 HassIO, HassioAPIError, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 537588e856adc9..2ebd3f6aab456b 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -29,6 +29,7 @@ Folder, IncorrectPasswordError, NewBackup, + RestoreBackupEvent, WrittenBackup, ) from homeassistant.core import HomeAssistant, callback @@ -227,11 +228,12 @@ async def async_create_backup( include_addons_set = supervisor_backups.AddonSet.ALL elif include_addons: include_addons_set = set(include_addons) - include_folders_set = ( - {supervisor_backups.Folder(folder) for folder in include_folders} - if include_folders - else None - ) + include_folders_set = { + supervisor_backups.Folder(folder) for folder in include_folders or [] + } + # Always include SSL if Home Assistant is included + if include_homeassistant: + include_folders_set.add(supervisor_backups.Folder.SSL) hassio_agents: list[SupervisorBackupAgent] = [ cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) @@ -275,7 +277,7 @@ async def _async_wait_for_backup( backup_id: str | None = None @callback - def on_progress(data: Mapping[str, Any]) -> None: + def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" nonlocal backup_id if data.get("done") is True: @@ -283,7 +285,7 @@ def on_progress(data: Mapping[str, Any]) -> None: backup_complete.set() try: - unsub = self._async_listen_job_events(backup.job_id, on_progress) + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -374,6 +376,7 @@ async def async_restore_backup( backup_id: str, *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], password: str | None, restore_addons: list[str] | None, @@ -437,13 +440,13 @@ async def async_restore_backup( restore_complete = asyncio.Event() @callback - def on_progress(data: Mapping[str, Any]) -> None: + def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" if data.get("done") is True: restore_complete.set() try: - unsub = self._async_listen_job_events(job.job_id, on_progress) + unsub = self._async_listen_job_events(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index fbb6a6b48f9bf5..7ff00b8e282801 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -5,9 +5,9 @@ from datetime import timedelta import logging import socket -from telnetlib import Telnet # pylint: disable=deprecated-module from typing import Any +from telnetlib import Telnet # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json index d280cfc1a2b35a..449b9f72fe79f7 100644 --- a/homeassistant/components/hdmi_cec/strings.json +++ b/homeassistant/components/hdmi_cec/strings.json @@ -2,7 +2,7 @@ "services": { "power_on": { "name": "Power on", - "description": "Power on all devices which supports it." + "description": "Powers on all devices which support this function." }, "select_device": { "name": "Select device", @@ -10,7 +10,7 @@ "fields": { "device": { "name": "[%key:common::config_flow::data::device%]", - "description": "Address of device to select. Can be entity_id, physical address or alias from configuration." + "description": "Address of device to select. Can be an entity ID, physical address or alias from configuration." } } }, @@ -42,7 +42,7 @@ }, "standby": { "name": "[%key:common::state::standby%]", - "description": "Standby all devices which supports it." + "description": "Places in standby all devices which support this function." }, "update": { "name": "Update", @@ -50,19 +50,19 @@ }, "volume": { "name": "Volume", - "description": "Increases or decreases volume of system.", + "description": "Increases or decreases the system volume.", "fields": { "down": { "name": "Down", - "description": "Decreases volume x levels." + "description": "Decreases the volume x levels." }, "mute": { "name": "Mute", - "description": "Mutes audio system." + "description": "Mutes the audio system." }, "up": { "name": "Up", - "description": "Increases volume x levels." + "description": "Increases the volume x levels." } } } diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 0cae943397d637..1004ffd273819c 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -13,6 +14,8 @@ HeosError, HeosOptions, HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, const as heos_const, ) @@ -27,10 +30,12 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import services @@ -46,6 +51,8 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + _LOGGER = logging.getLogger(__name__) @@ -62,12 +69,30 @@ class HeosRuntimeData: type HeosConfigEntry = ConfigEntry[HeosRuntimeData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the HEOS component.""" + services.register(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Initialize config entry which represents the HEOS controller.""" # For backwards compat if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) + # Migrate non-string device identifiers. + device_registry = dr.async_get(hass) + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ): + for domain, player_id in device.identifiers: + if domain == DOMAIN and not isinstance(player_id, str): + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, str(player_id))} + ) + break + host = entry.data[CONF_HOST] credentials: Credentials | None = None if entry.options: @@ -88,14 +113,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Auth failure handler must be added before connecting to the host, otherwise # the event will be missed when login fails during connection. - async def auth_failure(event: str) -> None: + async def auth_failure() -> None: """Handle authentication failure.""" - if event == heos_const.EVENT_USER_CREDENTIALS_INVALID: - entry.async_start_reauth(hass) + entry.async_start_reauth(hass) - entry.async_on_unload( - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure) - ) + entry.async_on_unload(controller.add_on_user_credentials_invalid(auth_failure)) try: # Auto reconnect only operates if initial connection was successful. @@ -141,7 +163,6 @@ async def disconnect_controller(event): controller_manager, group_manager, source_manager, players ) - services.register(hass, controller) group_manager.connect_update() entry.async_on_unload(group_manager.disconnect_update) @@ -153,20 +174,17 @@ async def disconnect_controller(event): async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" await entry.runtime_data.controller_manager.disconnect() - - services.remove(hass) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class ControllerManager: """Class that manages events of the controller.""" - def __init__(self, hass, controller): + def __init__(self, hass: HomeAssistant, controller: Heos) -> None: """Init the controller manager.""" self._hass = hass - self._device_registry = None - self._entity_registry = None + self._device_registry: dr.DeviceRegistry | None = None + self._entity_registry: er.EntityRegistry | None = None self.controller = controller async def connect_listeners(self): @@ -175,56 +193,59 @@ async def connect_listeners(self): self._entity_registry = er.async_get(self._hass) # Handle controller events - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event - ) + self.controller.add_on_controller_event(self._controller_event) # Handle connection-related events - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self._heos_event - ) + self.controller.add_on_heos_event(self._heos_event) async def disconnect(self): """Disconnect subscriptions.""" self.controller.dispatcher.disconnect_all() await self.controller.disconnect() - async def _controller_event(self, event, data): + async def _controller_event( + self, event: str, data: PlayerUpdateResult | None + ) -> None: """Handle controller event.""" if event == heos_const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + assert data is not None + self.update_ids(data.updated_player_ids) # Update players async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" - if event == heos_const.EVENT_CONNECTED: + if event == SignalHeosEvent.CONNECTED: try: # Retrieve latest players and refresh status data = await self.controller.load_players() - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + self.update_ids(data.updated_player_ids) except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players + _LOGGER.debug("HEOS Controller event called, calling dispatcher") async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" # mapped_ids contains the mapped IDs (new:old) - for new_id, old_id in mapped_ids.items(): + for old_id, new_id in mapped_ids.items(): # update device registry + assert self._device_registry is not None entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, old_id)} + identifiers={(DOMAIN, str(old_id))} ) - new_identifiers = {(DOMAIN, new_id)} + new_identifiers = {(DOMAIN, str(new_id))} if entry: self._device_registry.async_update_device( - entry.id, new_identifiers=new_identifiers + entry.id, + new_identifiers=new_identifiers, ) _LOGGER.debug( "Updated device %s identifiers to %s", entry.id, new_identifiers ) # update entity registry + assert self._entity_registry is not None entity_id = self._entity_registry.async_get_entity_id( Platform.MEDIA_PLAYER, DOMAIN, str(old_id) ) @@ -243,7 +264,7 @@ def __init__( ) -> None: """Init group manager.""" self._hass = hass - self._group_membership: dict[str, str] = {} + self._group_membership: dict[str, list[str]] = {} self._disconnect_player_added = None self._initialized = False self.controller = controller @@ -262,18 +283,18 @@ async def async_get_group_membership(self) -> dict[str, list[str]]: } try: - groups = await self.controller.get_groups(refresh=True) + groups = await self.controller.get_groups() except HeosError as err: _LOGGER.error("Unable to get HEOS group info: %s", err) return group_info_by_entity_id player_id_to_entity_id_map = self.entity_id_map for group in groups.values(): - leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) + leader_entity_id = player_id_to_entity_id_map.get(group.lead_player_id) member_entity_ids = [ - player_id_to_entity_id_map[member.player_id] - for member in group.members - if member.player_id in player_id_to_entity_id_map + player_id_to_entity_id_map[member] + for member in group.member_player_ids + if member in player_id_to_entity_id_map ] # Make sure the group leader is always the first element group_info = [leader_entity_id, *member_entity_ids] @@ -320,29 +341,26 @@ async def async_unjoin_player(self, player_id: int, player_entity_id: str): err, ) - async def async_update_groups(self, event, data=None): + async def async_update_groups(self) -> None: """Update the group membership from the controller.""" - if event in ( - heos_const.EVENT_GROUPS_CHANGED, - heos_const.EVENT_CONNECTED, - SIGNAL_HEOS_PLAYER_ADDED, - ): - if groups := await self.async_get_group_membership(): - self._group_membership = groups - _LOGGER.debug("Groups updated due to change event") - # Let players know to update - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - else: - _LOGGER.debug("Groups empty") + if groups := await self.async_get_group_membership(): + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + @callback def connect_update(self): """Connect listener for when groups change and signal player update.""" - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups - ) - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups - ) + + async def _on_controller_event(event: str, data: Any | None) -> None: + if event == heos_const.EVENT_GROUPS_CHANGED: + await self.async_update_groups() + + self.controller.add_on_controller_event(_on_controller_event) + self.controller.add_on_connected(self.async_update_groups) # When adding a new HEOS player we need to update the groups. async def _async_handle_player_added(): @@ -350,7 +368,7 @@ async def _async_handle_player_added(): # fully populated yet. This may only happen during early startup. if len(self.players) <= len(self.entity_id_map) and not self._initialized: self._initialized = True - await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + await self.async_update_groups() self._disconnect_player_added = async_dispatcher_connect( self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added @@ -416,7 +434,7 @@ async def play_source(self, source: str, player): None, ) if index is not None: - await player.play_favorite(index) + await player.play_preset_station(index) return input_source = next( @@ -428,7 +446,7 @@ async def play_source(self, source: str, player): None, ) if input_source is not None: - await player.play_input_source(input_source) + await player.play_input_source(input_source.media_id) return _LOGGER.error("Unknown source: %s", source) @@ -441,7 +459,7 @@ def get_current_source(self, now_playing_media): ( input_source.name for input_source in self.inputs - if input_source.input_name == now_playing_media.media_id + if input_source.media_id == now_playing_media.media_id ), None, ) @@ -456,7 +474,8 @@ def get_current_source(self, now_playing_media): None, ) - def connect_update(self, hass, controller): + @callback + def connect_update(self, hass: HomeAssistant, controller: Heos) -> None: """Connect listener for when sources change and signal player update. EVENT_SOURCES_CHANGED is often raised multiple times in response to a @@ -486,21 +505,22 @@ async def get_sources(): else: return favorites, inputs - async def update_sources(event, data=None): + async def _update_sources() -> None: + # If throttled, it will return None + if sources := await get_sources(): + self.favorites, self.inputs = sources + self.source_list = self._build_source_list() + _LOGGER.debug("Sources updated due to changed event") + # Let players know to update + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) + + async def _on_controller_event(event: str, data: Any | None) -> None: if event in ( heos_const.EVENT_SOURCES_CHANGED, heos_const.EVENT_USER_CHANGED, - heos_const.EVENT_CONNECTED, ): - # If throttled, it will return None - if sources := await get_sources(): - self.favorites, self.inputs = sources - self.source_list = self._build_source_list() - _LOGGER.debug("Sources updated due to changed event") - # Let players know to update - async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) - - controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, update_sources - ) - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources) + await _update_sources() + + controller.add_on_connected(_update_sources) + controller.add_on_user_credentials_invalid(_update_sources) + controller.add_on_controller_event(_on_controller_event) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index c47d83d3475e16..86d5123bccf787 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -5,10 +5,9 @@ from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse -from pyheos import CommandFailedError, Heos, HeosError, HeosOptions +from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -18,6 +17,10 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from .const import DOMAIN @@ -79,13 +82,9 @@ async def _validate_auth( # Attempt to login (both username and password provided) try: await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except CommandFailedError as err: - if err.error_id in (6, 8, 10): # Auth-specific errors - errors["base"] = "invalid_auth" - _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) - else: - errors["base"] = "unknown" - _LOGGER.exception("Unexpected error occurred during sign-in") + except CommandAuthenticationError as err: + errors["base"] = "invalid_auth" + _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) return False except HeosError: errors["base"] = "unknown" @@ -111,16 +110,14 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: return HeosOptionsFlowHandler() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Heos device.""" # Store discovered host if TYPE_CHECKING: assert discovery_info.ssdp_location hostname = urlparse(discovery_info.ssdp_location).hostname - friendly_name = ( - f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" - ) + friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" self.hass.data.setdefault(DOMAIN, {}) self.hass.data[DOMAIN][friendly_name] = hostname await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 20694196e824eb..6a631861b1c3dd 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/heos", "iot_class": "local_push", "loggers": ["pyheos"], - "requirements": ["pyheos==0.8.0"], + "requirements": ["pyheos==1.0.0"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index adbeadbc24f39c..69aedaa4648c7f 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -8,7 +8,14 @@ from operator import ior from typing import Any -from pyheos import HeosError, const as heos_const +from pyheos import ( + AddCriteriaType, + ControlType, + HeosError, + HeosPlayer, + PlayState, + const as heos_const, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -47,25 +54,25 @@ ) PLAY_STATE_TO_STATE = { - heos_const.PLAY_STATE_PLAY: MediaPlayerState.PLAYING, - heos_const.PLAY_STATE_STOP: MediaPlayerState.IDLE, - heos_const.PLAY_STATE_PAUSE: MediaPlayerState.PAUSED, + PlayState.PLAY: MediaPlayerState.PLAYING, + PlayState.STOP: MediaPlayerState.IDLE, + PlayState.PAUSE: MediaPlayerState.PAUSED, } CONTROL_TO_SUPPORT = { - heos_const.CONTROL_PLAY: MediaPlayerEntityFeature.PLAY, - heos_const.CONTROL_PAUSE: MediaPlayerEntityFeature.PAUSE, - heos_const.CONTROL_STOP: MediaPlayerEntityFeature.STOP, - heos_const.CONTROL_PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, - heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + ControlType.PLAY: MediaPlayerEntityFeature.PLAY, + ControlType.PAUSE: MediaPlayerEntityFeature.PAUSE, + ControlType.STOP: MediaPlayerEntityFeature.STOP, + ControlType.PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + ControlType.PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } HA_HEOS_ENQUEUE_MAP = { - None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END, - MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT, - MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW, + None: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: AddCriteriaType.ADD_TO_END, + MediaPlayerEnqueue.REPLACE: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: AddCriteriaType.PLAY_NEXT, + MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW, } _LOGGER = logging.getLogger(__name__) @@ -118,26 +125,31 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_name = None def __init__( - self, player, source_manager: SourceManager, group_manager: GroupManager + self, + player: HeosPlayer, + source_manager: SourceManager, + group_manager: GroupManager, ) -> None: """Initialize.""" self._media_position_updated_at = None - self._player = player + self._player: HeosPlayer = player self._source_manager = source_manager self._group_manager = group_manager self._attr_unique_id = str(player.player_id) + model_parts = player.model.split(maxsplit=1) + manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" + model = model_parts[1] if len(model_parts) == 2 else player.model self._attr_device_info = DeviceInfo( - identifiers={(HEOS_DOMAIN, player.player_id)}, - manufacturer="HEOS", - model=player.model, + identifiers={(HEOS_DOMAIN, str(player.player_id))}, + manufacturer=manufacturer, + model=model, name=player.name, + serial_number=player.serial, # Only available for some models sw_version=player.version, ) - async def _player_update(self, player_id, event): + async def _player_update(self, event): """Handle player attribute updated.""" - if self._player.player_id != player_id: - return if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) @@ -149,11 +161,7 @@ async def _heos_updated(self) -> None: async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change - self.async_on_remove( - self._player.heos.dispatcher.connect( - heos_const.SIGNAL_PLAYER_EVENT, self._player_update - ) - ) + self.async_on_remove(self._player.add_on_player_event(self._player_update)) # Update state when heos changes self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) @@ -268,7 +276,7 @@ async def async_play_media( ) if index is None: raise ValueError(f"Invalid favorite '{media_id}'") - await self._player.play_favorite(index) + await self._player.play_preset_station(index) return raise ValueError(f"Unsupported media type '{media_type}'") diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 85d6ef69d096cc..3135cca3f9d56d 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: todo - comment: Future enhancement to move custom actions for login/out into an options flow. + action-setup: done appropriate-polling: status: done comment: Integration is a local push integration @@ -16,7 +14,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -52,24 +50,18 @@ rules: 4. Recommend using snapshot in test_state_attributes. 5. Find a way to avoid using internal dispatcher in test_updates_from_connection_event. # Gold - devices: - status: todo - comment: | - The integraiton creates devices, but needs to stringify the id for the device identifier and - also migrate the device. + devices: done diagnostics: todo discovery-update-info: status: todo comment: Explore if this is possible. discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: - status: todo - comment: Has some troublehsooting setps, but needs to be improved + docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo entity-category: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 2ef80b6efd9959..a780c26fca6124 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,13 +1,14 @@ """Services for the HEOS integration.""" -import functools import logging -from pyheos import CommandFailedError, Heos, HeosError, const +from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from .const import ( ATTR_PASSWORD, @@ -26,48 +27,63 @@ HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant, controller: Heos): +def register(hass: HomeAssistant): """Register HEOS services.""" hass.services.async_register( DOMAIN, SERVICE_SIGN_IN, - functools.partial(_sign_in_handler, controller), + _sign_in_handler, schema=HEOS_SIGN_IN_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SIGN_OUT, - functools.partial(_sign_out_handler, controller), + _sign_out_handler, schema=HEOS_SIGN_OUT_SCHEMA, ) -def remove(hass: HomeAssistant): - """Unregister HEOS services.""" - hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN) - hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT) +def _get_controller(hass: HomeAssistant) -> Heos: + """Get the HEOS controller instance.""" + _LOGGER.warning( + "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" + ) + ir.async_create_issue( + hass, + DOMAIN, + "sign_in_out_deprecated", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="sign_in_out_deprecated", + ) -async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None: + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) + if not entry or not entry.state == ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="integration_not_loaded" + ) + return entry.runtime_data.controller_manager.controller + + +async def _sign_in_handler(service: ServiceCall) -> None: """Sign in to the HEOS account.""" - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign in because HEOS is not connected") - return + controller = _get_controller(service.hass) username = service.data[ATTR_USERNAME] password = service.data[ATTR_PASSWORD] try: await controller.sign_in(username, password) - except CommandFailedError as err: + except CommandAuthenticationError as err: _LOGGER.error("Sign in failed: %s", err) except HeosError as err: _LOGGER.error("Unable to sign in: %s", err) -async def _sign_out_handler(controller: Heos, service: ServiceCall) -> None: +async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign out because HEOS is not connected") - return + + controller = _get_controller(service.hass) try: await controller.sign_out() except HeosError as err: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index bc06390fe73eaf..0506c37fa77930 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -89,5 +89,16 @@ "name": "Sign out", "description": "Signs out of the HEOS account." } + }, + "exceptions": { + "integration_not_loaded": { + "message": "The HEOS integration is not loaded" + } + }, + "issues": { + "sign_in_out_deprecated": { + "title": "HEOS Actions Deprecated", + "description": "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release. Enter your HEOS Account credentials in the configuration options and the integration will manage authentication automatically." + } } } diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 68f79439162adc..1dc1eaabcaa7b5 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -195,7 +195,7 @@ async def async_update(self) -> None: fan_mode = status["wind_status"] self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] - swing_mode = f'{status["up_down"]}{status["left_right"]}' + swing_mode = f"{status['up_down']}{status['left_right']}" self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7241e1fac9ad79..ba4614bbc35ce2 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -111,10 +111,12 @@ async def get( # end_time. If it's false, we know there are no states in the # database up until end_time. (end_time and not has_states_before(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): return self.json([]) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 35f8ed5f1acdca..e6c9145321333c 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -146,10 +146,12 @@ async def ws_get_history_during_period( # end_time. If it's false, we know there are no states in the # database up until end_time. (end_time and not has_states_before(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): connection.send_result(msg["id"], {}) diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index 1209e8c8f05980..f564869020137a 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -21,7 +21,7 @@ def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: self.hive = hive self.device = hive_device self._attr_name = self.device["haName"] - self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_unique_id = f"{self.device['hiveID']}-{self.device['hiveType']}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device["device_id"])}, model=self.device["deviceData"]["model"], diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 870223f8fe6e6b..f68478516abbfd 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.16"] + "requirements": ["pyhive-integration==1.0.1"] } diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index c8062a64ade9b6..219776ad7e6da2 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -35,7 +35,7 @@ }, "error": { "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password please try again.", + "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -52,7 +52,7 @@ "title": "Options for Hive", "description": "Update the scan interval to poll for data more often.", "data": { - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } } } @@ -60,15 +60,15 @@ "services": { "boost_heating_on": { "name": "Boost heating on", - "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.", + "description": "Sets the boost mode ON, defining the period of time and the desired target temperature for the boost.", "fields": { "time_period": { - "name": "Time Period", - "description": "Set the time period for the boost." + "name": "[%key:component::hive::services::boost_hot_water::fields::time_period::name%]", + "description": "[%key:component::hive::services::boost_hot_water::fields::time_period::description%]" }, "temperature": { "name": "Temperature", - "description": "Set the target temperature for the boost period." + "description": "The target temperature for the boost period." } } }, @@ -78,21 +78,21 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to turn boost off." + "description": "The entity ID to turn boost off." } } }, "boost_hot_water": { "name": "Boost hotwater", - "description": "Sets the boost mode ON or OFF defining the period of time for the boost.", + "description": "Sets the boost mode ON or OFF, defining the period of time for the boost.", "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to boost." + "description": "The entity ID to boost." }, "time_period": { "name": "Time period", - "description": "Set the time period for the boost." + "description": "The time period for the boost." }, "on_off": { "name": "[%key:common::config_flow::data::mode%]", diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 00a71351ca7781..6d29e09c0f8b89 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -30,6 +31,30 @@ SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) +def get_optional_provinces(country: str) -> list[Any]: + """Return the country provinces (territories). + + Some territories can have extra or different holidays + from another within the same country. + Some territories can have different names (aliases). + """ + province_options: list[Any] = [] + + if provinces := SUPPORTED_COUNTRIES[country]: + country_data = country_holidays(country, years=dt_util.utcnow().year) + if country_data.subdivisions_aliases and ( + subdiv_aliases := country_data.get_subdivision_aliases() + ): + province_options = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces + + return province_options + + def get_optional_categories(country: str) -> list[str]: """Return the country categories. @@ -45,7 +70,7 @@ def get_optional_categories(country: str) -> list[str]: def get_options_schema(country: str) -> vol.Schema: """Return the options schema.""" schema = {} - if provinces := SUPPORTED_COUNTRIES[country]: + if provinces := get_optional_provinces(country): schema[vol.Optional(CONF_PROVINCE)] = SelectSelector( SelectSelectorConfig( options=provinces, diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 33cae2315956eb..09943faf0a2f29 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.63", "babel==2.15.0"] + "requirements": ["holidays==0.64", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 818c4e6fe19384..d7c042c2a91718 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -168,7 +168,7 @@ async def _run_appliance_service[*_Ts]( error_translation_placeholders: dict[str, str], ) -> None: try: - await hass.async_add_executor_job(getattr(appliance, method), args) + await hass.async_add_executor_job(getattr(appliance, method), *args) except api.HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index c97b3db28e0c9c..a4a5861afbe098 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -220,7 +220,7 @@ def get_entities() -> list[HomeConnectProgramSelectEntity]: with contextlib.suppress(HomeConnectError): programs = device.appliance.get_programs_available() if programs: - for program in programs: + for program in programs.copy(): if program not in PROGRAMS_TRANSLATION_KEYS_MAP: programs.remove(program) if program not in programs_not_found: diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3ccf55bac6e109..7b82ef8b676fe6 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,13 +1,10 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import cast -from homeconnect.api import HomeConnectError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -22,6 +19,7 @@ from . import HomeConnectConfigEntry from .const import ( + APPLIANCES_WITH_PROGRAMS, ATTR_VALUE, BSH_DOOR_STATE, BSH_OPERATION_STATE, @@ -51,27 +49,35 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): default_value: str | None = None appliance_types: tuple[str, ...] | None = None - sign: int = 1 BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( key="BSH.Common.Option.RemainingProgramTime", device_class=SensorDeviceClass.TIMESTAMP, - sign=1, translation_key="program_finish_time", + appliance_types=( + "CoffeMaker", + "CookProcessor", + "Dishwasher", + "Dryer", + "Hood", + "Oven", + "Washer", + "WasherDryer", + ), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.Duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - sign=1, + appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.ProgramProgress", native_unit_of_measurement=PERCENTAGE, - sign=1, translation_key="program_progress", + appliance_types=APPLIANCES_WITH_PROGRAMS, ), ) @@ -269,11 +275,12 @@ def get_entities() -> list[SensorEntity]: if description.appliance_types and device.appliance.type in description.appliance_types ) - with contextlib.suppress(HomeConnectError): - if device.appliance.get_programs_available(): - entities.extend( - HomeConnectSensor(device, desc) for desc in BSH_PROGRAM_SENSORS - ) + entities.extend( + HomeConnectProgramSensor(device, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types + and device.appliance.type in desc.appliance_types + ) entities.extend( HomeConnectSensor(device, description) for description in SENSORS @@ -289,11 +296,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - @property - def available(self) -> bool: - """Return true if the sensor is available.""" - return self._attr_native_value is not None - async def async_update(self) -> None: """Update the sensor's status.""" appliance_status = self.device.appliance.status @@ -311,30 +313,17 @@ async def async_update(self) -> None: self._attr_native_value = None elif ( self._attr_native_value is not None - and self.entity_description.sign == 1 and isinstance(self._attr_native_value, datetime) and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - elif ( - BSH_OPERATION_STATE - in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ): - seconds = self.entity_description.sign * float(status[ATTR_VALUE]) + else: + seconds = float(status[ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta( seconds=seconds ) - else: - self._attr_native_value = None case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off @@ -345,3 +334,34 @@ async def async_update(self) -> None: case _: self._attr_native_value = status.get(ATTR_VALUE) _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + + +class HomeConnectProgramSensor(HomeConnectSensor): + """Sensor class for Home Connect sensors that reports information related to the running program.""" + + program_running: bool = False + + @property + def available(self) -> bool: + """Return true if the sensor is available.""" + # These sensors are only available if the program is running, paused or finished. + # Otherwise, some sensors report erroneous values. + return super().available and self.program_running + + async def async_update(self) -> None: + """Update the sensor's status.""" + self.program_running = ( + BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) + and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] + and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ) + if self.program_running: + await super().async_update() + else: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index f5c3cf69807a2f..7ededaae5b72d0 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -23,7 +23,7 @@ }, "exceptions": { "appliance_not_found": { - "message": "Appliance for device id {device_id} not found" + "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { "message": "Error turning on {entity_id}: {description}" @@ -103,7 +103,7 @@ "fields": { "device_id": { "name": "Device ID", - "description": "Id of the device." + "description": "ID of the device." }, "program": { "name": "Program", "description": "Program to select." }, "key": { "name": "Option key", "description": "Key of the option." }, diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 305077bfb861c5..1bd02e03eb14eb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -27,7 +27,6 @@ ATTR_VALUE, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, @@ -98,6 +97,12 @@ ) +POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( + key=BSH_POWER_STATE, + translation_key="power", +) + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -117,7 +122,8 @@ def get_entities() -> list[SwitchEntity]: HomeConnectProgramSwitch(device, program) for program in programs ) - entities.append(HomeConnectPowerSwitch(device)) + if BSH_POWER_STATE in device.appliance.status: + entities.append(HomeConnectPowerSwitch(device)) entities.extend( HomeConnectSwitch(device, description) for description in SWITCHES @@ -310,7 +316,7 @@ def __init__(self, device: HomeConnectDevice) -> None: """Initialize the entity.""" super().__init__( device, - SwitchEntityDescription(key=BSH_POWER_STATE, translation_key="power"), + POWER_SWITCH_DESCRIPTION, ) if ( power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( @@ -396,23 +402,6 @@ async def async_update(self) -> None: == self.power_off_state ): self._attr_is_on = False - elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( - ATTR_VALUE, None - ) in [ - "BSH.Common.EnumType.OperationState.Ready", - "BSH.Common.EnumType.OperationState.DelayedStart", - "BSH.Common.EnumType.OperationState.Run", - "BSH.Common.EnumType.OperationState.Pause", - "BSH.Common.EnumType.OperationState.ActionRequired", - "BSH.Common.EnumType.OperationState.Aborting", - "BSH.Common.EnumType.OperationState.Finished", - ]: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) - == "BSH.Common.EnumType.OperationState.Inactive" - ): - self._attr_is_on = False else: self._attr_is_on = None _LOGGER.debug("Updated, new state: %s", self._attr_is_on) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 2fbf8bcb6bcf55..b3b4f68ba96da3 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -19,6 +19,7 @@ OptionsFlow, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant from .util import get_hardware_variant, get_usb_service_info @@ -69,7 +70,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" super().__init__(*args, **kwargs) - self._usb_info: usb.UsbServiceInfo | None = None + self._usb_info: UsbServiceInfo | None = None self._hw_variant: HardwareVariant | None = None @staticmethod @@ -85,9 +86,7 @@ def async_get_options_flow( return HomeAssistantSkyConnectOptionsFlowHandler(config_entry) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" device = discovery_info.device vid = discovery_info.vid @@ -145,11 +144,8 @@ async def _async_serial_port_settings( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" - usb_dev = self.config_entry.data["device"] - # The call to get_serial_by_id can be removed in HA Core 2024.1 - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) return silabs_multiprotocol_addon.SerialPortSettings( - device=dev_path, + device=self.config_entry.data["device"], baudrate="115200", flow_control=True, ) diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f8c5d004d0e6cf..c463c1b927549b 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -4,17 +4,17 @@ import logging -from homeassistant.components import usb from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import HardwareVariant _LOGGER = logging.getLogger(__name__) -def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo: +def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo: """Return UsbServiceInfo.""" - return usb.UsbServiceInfo( + return UsbServiceInfo( device=config_entry.data["device"], vid=config_entry.data["vid"], pid=config_entry.data["pid"], diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index c6546596fa7476..b594b23cc591c8 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -29,7 +29,7 @@ POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] -def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute: +def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: """Return the attribute used for opening/closing the cover.""" # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None: @@ -39,12 +39,12 @@ def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute: def get_cover_features( - node: HomeeNode, open_close_attribute: HomeeAttribute + node: HomeeNode, open_close_attribute: HomeeAttribute | None ) -> CoverEntityFeature: """Determine the supported cover features of a homee node based on the available attributes.""" features = CoverEntityFeature(0) - if open_close_attribute.editable: + if (open_close_attribute is not None) and open_close_attribute.editable: features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) @@ -111,8 +111,11 @@ def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: node, self._open_close_attribute ) self._attr_device_class = get_device_class(node) - - self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}" + self._attr_unique_id = ( + f"{self._attr_unique_id}-{self._open_close_attribute.id}" + if self._open_close_attribute is not None + else f"{self._attr_unique_id}-0" + ) @property def current_cover_position(self) -> int | None: @@ -194,6 +197,7 @@ def is_closed(self) -> bool | None: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: await self.async_set_value(self._open_close_attribute, 0) else: @@ -201,6 +205,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" + assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: await self.async_set_value(self._open_close_attribute, 1) else: @@ -217,11 +222,12 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(AttributeType.POSITION, homee_position) + await self.async_set_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.async_set_value(self._open_close_attribute, 2) + if self._open_close_attribute is not None: + await self.async_set_value(self._open_close_attribute, 2) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -229,9 +235,9 @@ async def async_open_cover_tilt(self, **kwargs: Any) -> None: AttributeType.SLAT_ROTATION_IMPULSE ) if not slat_attribute.is_reversed: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + await self.async_set_value(slat_attribute, 2) else: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + await self.async_set_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -239,9 +245,9 @@ async def async_close_cover_tilt(self, **kwargs: Any) -> None: AttributeType.SLAT_ROTATION_IMPULSE ) if not slat_attribute.is_reversed: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + await self.async_set_value(slat_attribute, 1) else: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + await self.async_set_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -256,6 +262,4 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value( - AttributeType.SHUTTER_SLAT_POSITION, homee_position - ) + await self.async_set_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index c3c2d860cc0f2d..91b23b5a2c2c12 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -1,7 +1,7 @@ """Base Entities for Homee integration.""" from pyHomee.const import AttributeType, NodeProfile, NodeState -from pyHomee.model import HomeeNode +from pyHomee.model import HomeeAttribute, HomeeNode from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -69,16 +69,10 @@ def has_attribute(self, attribute_type: AttributeType) -> bool: """Check if an attribute of the given type exists.""" return attribute_type in self._node.attribute_map - async def async_set_value(self, attribute_type: int, value: float) -> None: - """Set an attribute value on the homee node.""" - await self.async_set_value_by_id( - self._node.get_attribute_by_type(attribute_type).id, value - ) - - async def async_set_value_by_id(self, attribute_id: int, value: float) -> None: + async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data - await homee.set_value(self._node.id, attribute_id, value) + await homee.set_value(attribute.node_id, attribute.id, value) def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index d6daeb49f82974..a477dde9c9db0c 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -102,8 +102,8 @@ def get_or_allocate_iid( char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None # Allocation key must be a string since we are saving it to JSON allocation_key = ( - f'{service_hap_type}_{service_unique_id or ""}_' - f'{char_hap_type or ""}_{char_unique_id or ""}' + f"{service_hap_type}_{service_unique_id or ''}_" + f"{char_hap_type or ''}_{char_unique_id or ''}" ) # AID must be a string since JSON keys cannot be int aid_str = str(aid) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index cf74bcc7d67e88..d7ea293b5dcd4f 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.2.2", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 6752633f3d268e..651033682cf31e 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -409,11 +409,8 @@ def move_cover(self, value: int) -> None: """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) - if ( - self._supports_stop - and value > 70 - or not self._supports_stop - and value >= 50 + if (self._supports_stop and value > 70) or ( + not self._supports_stop and value >= 50 ): service, position = (SERVICE_OPEN_COVER, 100) elif value < 30 or not self._supports_stop: diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cde80178c5ee7c..212b3228154db7 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -52,6 +52,7 @@ PROP_MIN_VALUE, SERV_LIGHTBULB, ) +from .util import get_min_max _LOGGER = logging.getLogger(__name__) @@ -120,12 +121,14 @@ def __init__(self, *args: Any) -> None: self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = color_temperature_kelvin_to_mired( + min_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = color_temperature_kelvin_to_mired( + max_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) + # Ensure min is less than max + self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds self.char_color_temp = serv_light.configure_char( @@ -282,7 +285,11 @@ def async_update_state(self, new_state: State) -> None: hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 - elif hue_sat := attributes.get(ATTR_HS_COLOR): + elif ( + (hue_sat := attributes.get(ATTR_HS_COLOR)) + and isinstance(hue_sat, (list, tuple)) + and len(hue_sat) == 2 + ): hue, saturation = hue_sat else: hue = None diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 91bab2d470a616..4dda495ce77284 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -14,6 +14,7 @@ ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, @@ -21,6 +22,7 @@ ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, @@ -90,7 +92,7 @@ SERV_FANV2, SERV_THERMOSTAT, ) -from .util import temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -208,7 +210,10 @@ def __init__(self, *args: Any) -> None: self.fan_chars: list[str] = [] attributes = state.attributes - min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity, _ = get_min_max( + attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY), + attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY), + ) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -839,6 +844,9 @@ def _get_temperature_range_from_state( else: max_temp = default_max + # Handle reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + # Homekit only supports 10-38, overwriting # the max to appears to work, but less than 0 causes # a crash on the home app diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b958817bbacd72..f32c4f55a0fa13 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -48,7 +48,7 @@ def __init__( for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") - unique_id = f'{type_}-{subtype or ""}' + unique_id = f"{type_}-{subtype or ''}" entity_id: str | None = None if (entity_id_or_uuid := trigger.get("entity_id")) and ( entry := ent_reg.async_get(entity_id_or_uuid) @@ -122,7 +122,7 @@ async def async_trigger( """ reason = "" if "trigger" in run_variables and "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" _LOGGER.debug("Button triggered%s - %s", reason, run_variables) idx = int(run_variables["trigger"]["idx"]) self.triggers[idx].set_value(0) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index d339aa6aded34a..a0dfcea7616556 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -643,7 +643,8 @@ def state_needs_accessory_mode(state: State) -> bool: state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) - or state.domain == REMOTE_DOMAIN + ) or ( + state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY ) @@ -655,3 +656,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo old_state = event_data["old_state"] new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) + + +def get_min_max(value1: float, value2: float) -> tuple[float, float]: + """Return the minimum and maximum of two values. + + HomeKit will go unavailable if the min and max are reversed + so we make sure the min is always the min and the max is always the max + as any mistakes made in integrations will cause the entire + bridge to go unavailable. + """ + return min(value1, value2), max(value1, value2) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 9e67d618079e18..0acf57fe55b746 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -19,11 +19,14 @@ from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, KNOWN_DEVICES @@ -189,7 +192,7 @@ def _hkid_is_homekit(self, hkid: str) -> bool: return False async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered HomeKit accessory. @@ -202,7 +205,7 @@ async def async_step_zeroconf( key.lower(): value for (key, value) in discovery_info.properties.items() } - if zeroconf.ATTR_PROPERTIES_ID not in properties: + if ATTR_PROPERTIES_ID not in properties: # This can happen if the TXT record is received after the PTR record # we will wait for the next update in this case _LOGGER.debug( @@ -216,7 +219,7 @@ async def async_step_zeroconf( # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 52f22bcc9f4fed..211aec2c2d5fd9 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -323,8 +323,7 @@ async def async_setup(self) -> None: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - name=f"HomeKit Device {self.unique_id} BLE availability " - "check poll", + name=f"HomeKit Device {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index bba67e10d4c2f7..2b72794b323090 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py new file mode 100644 index 00000000000000..8fb558b2b34cec --- /dev/null +++ b/homeassistant/components/homematicip_cloud/event.py @@ -0,0 +1,94 @@ +"""Support for HomematicIP Cloud events.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homematicip.aio.device import Device + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import HomematicipGenericEntity +from .hap import HomematicipHAP + + +@dataclass(frozen=True, kw_only=True) +class HmipEventEntityDescription(EventEntityDescription): + """Description of a HomematicIP Cloud event.""" + + +EVENT_DESCRIPTIONS = { + "doorbell": HmipEventEntityDescription( + key="doorbell", + translation_key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP cover from a config entry.""" + hap = hass.data[DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorBellEvent( + hap, + device, + channel.index, + EVENT_DESCRIPTIONS["doorbell"], + ) + for device in hap.home.devices + for channel in device.functionalChannels + if channel.channelRole == "DOOR_BELL_INPUT" + ) + + +class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): + """Event class for HomematicIP doorbell events.""" + + _attr_device_class = EventDeviceClass.DOORBELL + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + description: HmipEventEntityDescription, + ) -> None: + """Initialize the event.""" + super().__init__( + hap, + device, + post=description.key, + channel=channel, + is_multi_channel=False, + ) + + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self.functional_channel.add_on_channel_event_handler(self._async_handle_event) + + @callback + def _async_handle_event(self, *args, **kwargs) -> None: + """Handle the event fired by the functional channel.""" + event_types = self.entity_description.event_types + if TYPE_CHECKING: + assert event_types is not None + + self._trigger_event(event_type=event_types[0]) + self.async_write_ha_state() diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index a44d0586952a66..6fc422498abc3f 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.5"] + "requirements": ["homematicip==1.1.6"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c44d280c190836..9ed9b33d7c7a9d 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -93,7 +93,7 @@ } -async def async_setup_entry( # noqa: C901 +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 4733bc67073c56..a911f5398da840 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,8 +1,12 @@ """The Homewizard integration.""" +from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 + from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -12,7 +16,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = HWEnergyDeviceUpdateCoordinator(hass) + + api: HomeWizardEnergy + + if token := entry.data.get(CONF_TOKEN): + api = HomeWizardEnergyV2( + entry.data[CONF_IP_ADDRESS], + token=token, + clientsession=async_get_clientsession(hass), + ) + else: + api = HomeWizardEnergyV1( + entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) + + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 7b05cb95271b45..b86f797ec2d377 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -19,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Identify button.""" - if entry.runtime_data.supports_identify(): + if entry.runtime_data.data.device.supports_identify(): async_add_entities([HomeWizardIdentifyButton(entry.runtime_data)]) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index a6e4356328e8a3..fe78385381c21f 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -3,21 +3,21 @@ from __future__ import annotations from collections.abc import Mapping -import logging -from typing import Any, NamedTuple +from typing import Any from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.models import Device +from homewizard_energy.models import Device import voluptuous as vol -from homeassistant.components import onboarding, zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_API_ENABLED, @@ -25,26 +25,19 @@ CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - - -class DiscoveryData(NamedTuple): - """User metadata.""" - - ip: str - product_name: str - product_type: str - serial: str - class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for P1 meter.""" VERSION = 1 - discovery: DiscoveryData + ip_address: str | None = None + product_name: str | None = None + product_type: str | None = None + serial: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -55,7 +48,7 @@ async def async_step_user( try: device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( @@ -81,7 +74,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if ( @@ -96,16 +89,12 @@ async def async_step_zeroconf( if (discovery_info.properties[CONF_PATH]) != "/api/v1": return self.async_abort(reason="unsupported_api_version") - self.discovery = DiscoveryData( - ip=discovery_info.host, - product_type=discovery_info.properties[CONF_PRODUCT_TYPE], - product_name=discovery_info.properties[CONF_PRODUCT_NAME], - serial=discovery_info.properties[CONF_SERIAL], - ) + self.ip_address = discovery_info.host + self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] + self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] + self.serial = discovery_info.properties[CONF_SERIAL] - await self.async_set_unique_id( - f"{self.discovery.product_type}_{self.discovery.serial}" - ) + await self.async_set_unique_id(f"{self.product_type}_{self.serial}") self._abort_if_unique_id_configured( updates={CONF_IP_ADDRESS: discovery_info.host} ) @@ -122,7 +111,7 @@ async def async_step_dhcp( try: device = await self._async_try_connect(discovery_info.ip) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) return self.async_abort(reason="unknown") await self.async_set_unique_id( @@ -142,34 +131,39 @@ async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + assert self.ip_address + assert self.product_name + assert self.product_type + assert self.serial + errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.discovery.ip) + await self._async_try_connect(self.ip_address) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: return self.async_create_entry( - title=self.discovery.product_name, - data={CONF_IP_ADDRESS: self.discovery.ip}, + title=self.product_name, + data={CONF_IP_ADDRESS: self.ip_address}, ) self._set_confirm_only() # We won't be adding mac/serial to the title for devices # that users generally don't have multiple of. - name = self.discovery.product_name - if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: - name = f"{name} ({self.discovery.serial})" + name = self.product_name + if self.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.serial})" self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", description_placeholders={ - CONF_PRODUCT_TYPE: self.discovery.product_type, - CONF_SERIAL: self.discovery.serial, - CONF_IP_ADDRESS: self.discovery.ip, + CONF_PRODUCT_TYPE: self.product_type, + CONF_SERIAL: self.serial, + CONF_IP_ADDRESS: self.ip_address, }, errors=errors, ) @@ -190,7 +184,7 @@ async def async_step_reauth_confirm( try: await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) @@ -206,8 +200,9 @@ async def async_step_reconfigure( if user_input: try: device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( @@ -252,7 +247,7 @@ async def _async_try_connect(ip_address: str) -> Device: ) from ex except UnsupportedError as ex: - _LOGGER.error("API version unsuppored") + LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex except RequestError as ex: @@ -261,7 +256,7 @@ async def _async_try_connect(ip_address: str) -> Device: ) from ex except Exception as ex: - _LOGGER.exception("Unexpected exception") + LOGGER.exception("Unexpected exception") raise AbortFlow("unknown_error") from ex finally: diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 809ecc1416bb40..4bed4675833ece 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -2,12 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from homewizard_energy.v1.models import Data, Device, State, System - from homeassistant.const import Platform DOMAIN = "homewizard" @@ -23,13 +20,3 @@ CONF_SERIAL = "serial" UPDATE_INTERVAL = timedelta(seconds=5) - - -@dataclass -class DeviceResponseEntry: - """Dict describing a single response entry.""" - - device: Device - data: Data - state: State | None = None - system: System | None = None diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 8f5045d3b94ce5..7024c760b93005 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -2,68 +2,34 @@ from __future__ import annotations -import logging - -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE -from homewizard_energy.v1.models import Device +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" - api: HomeWizardEnergyV1 + api: HomeWizardEnergy api_disabled: bool = False - _unsupported_error: bool = False - config_entry: ConfigEntry - def __init__( - self, - hass: HomeAssistant, - ) -> None: + def __init__(self, hass: HomeAssistant, api: HomeWizardEnergy) -> None: """Initialize update coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.api = HomeWizardEnergyV1( - self.config_entry.data[CONF_IP_ADDRESS], - clientsession=async_get_clientsession(hass), - ) + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.api = api async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" try: - data = DeviceResponseEntry( - device=await self.api.device(), - data=await self.api.data(), - ) - - try: - if self.supports_state(data.device): - data.state = await self.api.state() - - data.system = await self.api.system() - - except UnsupportedError as ex: - # Old firmware, ignore - if not self._unsupported_error: - self._unsupported_error = True - _LOGGER.warning( - "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.config_entry.title, - ex, - ) + data = await self.api.combined() except RequestError as ex: raise UpdateFailed( @@ -89,18 +55,3 @@ async def _async_update_data(self) -> DeviceResponseEntry: self.data = data return data - - def supports_state(self, device: Device | None = None) -> bool: - """Return True if the device supports state.""" - - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_STATE - - def supports_identify(self, device: Device | None = None) -> bool: - """Return True if the device supports identify.""" - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_IDENTIFY diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 128e70d276a4c7..c776cdb18f203d 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -13,11 +13,12 @@ TO_REDACT = { CONF_IP_ADDRESS, + "gas_unique_id", + "id", "serial", - "wifi_ssid", - "unique_meter_id", "unique_id", - "gas_unique_id", + "unique_meter_id", + "wifi_ssid", } @@ -27,23 +28,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" data = entry.runtime_data.data - state: dict[str, Any] | None = None - if data.state: - state = asdict(data.state) - - system: dict[str, Any] | None = None - if data.system: - system = asdict(data.system) - return async_redact_data( { "entry": async_redact_data(entry.data, TO_REDACT), - "data": { - "device": asdict(data.device), - "data": asdict(data.data), - "state": state, - "system": system, - }, + "data": asdict(data), }, TO_REDACT, ) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 0aea899c04460b..1090f561838620 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -22,9 +22,7 @@ def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model_id=coordinator.data.device.product_type, - model=coordinator.data.device.product.name - if coordinator.data.device.product - else None, + model=coordinator.data.device.model_name, ) if (serial_number := coordinator.data.device.serial) is not None: diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 13bfc512551517..fc060961d107c9 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v7.0.0"], + "requirements": ["python-homewizard-energy==v8.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 1ed4c642f6b904..5806295fc812f6 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,7 +6,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import brightness_to_value, value_to_brightness from . import HomeWizardConfigEntry from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -22,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up numbers for device.""" - if entry.runtime_data.supports_state(): + if entry.runtime_data.data.device.supports_state(): async_add_entities([HWEnergyNumberEntity(entry.runtime_data)]) @@ -46,22 +45,21 @@ def __init__( @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set( - brightness=value_to_brightness((0, 100), value) - ) + await self.coordinator.api.system(status_led_brightness_pct=int(value)) await self.coordinator.async_refresh() @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.coordinator.data.state is not None + return super().available and self.coordinator.data.system is not None @property def native_value(self) -> float | None: """Return the current value.""" if ( - not self.coordinator.data.state - or (brightness := self.coordinator.data.state.brightness) is None + not self.coordinator.data.system + or (brightness := self.coordinator.data.system.status_led_brightness_pct) + is None ): return None - return round(brightness_to_value((0, 100), brightness)) + return round(brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8b822bffc507c2..8a9738e7ae77ba 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Final -from homewizard_energy.v1.models import Data, ExternalDevice +from homewizard_energy.models import ExternalDevice, Measurement from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -46,9 +46,9 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Data], bool] = lambda data: True - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], StateType] + enabled_fn: Callable[[Measurement], bool] = lambda x: True + has_fn: Callable[[Measurement], bool] + value_fn: Callable[[Measurement], StateType] @dataclass(frozen=True, kw_only=True) @@ -69,8 +69,8 @@ def to_percentage(value: float | None) -> float | None: key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.smr_version is not None, - value_fn=lambda data: data.smr_version, + has_fn=lambda data: data.protocol_version is not None, + value_fn=lambda data: data.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", @@ -83,8 +83,8 @@ def to_percentage(value: float | None) -> float | None: key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_meter_id is not None, - value_fn=lambda data: data.unique_meter_id, + has_fn=lambda data: data.unique_id is not None, + value_fn=lambda data: data.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", @@ -96,10 +96,8 @@ def to_percentage(value: float | None) -> float | None: HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.active_tariff is not None, - value_fn=lambda data: ( - None if data.active_tariff is None else str(data.active_tariff) - ), + has_fn=lambda data: data.tariff is not None, + value_fn=lambda data: None if data.tariff is None else str(data.tariff), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -119,8 +117,8 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_kwh is not None, - value_fn=lambda data: data.total_energy_import_kwh, + has_fn=lambda data: data.energy_import_kwh is not None, + value_fn=lambda data: data.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -131,10 +129,10 @@ def to_percentage(value: float | None) -> float | None: state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_import_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_import_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.total_energy_import_t1_kwh, + value_fn=lambda data: data.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -143,8 +141,8 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t2_kwh is not None, - value_fn=lambda data: data.total_energy_import_t2_kwh, + has_fn=lambda data: data.energy_import_t2_kwh is not None, + value_fn=lambda data: data.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -153,8 +151,8 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t3_kwh is not None, - value_fn=lambda data: data.total_energy_import_t3_kwh, + has_fn=lambda data: data.energy_import_t3_kwh is not None, + value_fn=lambda data: data.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -163,8 +161,8 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t4_kwh is not None, - value_fn=lambda data: data.total_energy_import_t4_kwh, + has_fn=lambda data: data.energy_import_t4_kwh is not None, + value_fn=lambda data: data.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -172,9 +170,9 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_kwh != 0, - value_fn=lambda data: data.total_energy_export_kwh, + has_fn=lambda data: data.energy_export_kwh is not None, + enabled_fn=lambda data: data.energy_export_kwh != 0, + value_fn=lambda data: data.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -185,11 +183,11 @@ def to_percentage(value: float | None) -> float | None: state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_export_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_export_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, - value_fn=lambda data: data.total_energy_export_t1_kwh, + enabled_fn=lambda data: data.energy_export_t1_kwh != 0, + value_fn=lambda data: data.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -198,9 +196,9 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, - value_fn=lambda data: data.total_energy_export_t2_kwh, + has_fn=lambda data: data.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.energy_export_t2_kwh != 0, + value_fn=lambda data: data.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -209,9 +207,9 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, - value_fn=lambda data: data.total_energy_export_t3_kwh, + has_fn=lambda data: data.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.energy_export_t3_kwh != 0, + value_fn=lambda data: data.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -220,9 +218,9 @@ def to_percentage(value: float | None) -> float | None: native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, - value_fn=lambda data: data.total_energy_export_t4_kwh, + has_fn=lambda data: data.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.energy_export_t4_kwh != 0, + value_fn=lambda data: data.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -230,8 +228,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_w is not None, - value_fn=lambda data: data.active_power_w, + has_fn=lambda data: data.power_w is not None, + value_fn=lambda data: data.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -241,8 +239,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l1_w is not None, - value_fn=lambda data: data.active_power_l1_w, + has_fn=lambda data: data.power_l1_w is not None, + value_fn=lambda data: data.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -252,8 +250,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l2_w is not None, - value_fn=lambda data: data.active_power_l2_w, + has_fn=lambda data: data.power_l2_w is not None, + value_fn=lambda data: data.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -263,8 +261,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l3_w is not None, - value_fn=lambda data: data.active_power_l3_w, + has_fn=lambda data: data.power_l3_w is not None, + value_fn=lambda data: data.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -272,8 +270,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_v is not None, - value_fn=lambda data: data.active_voltage_v, + has_fn=lambda data: data.voltage_v is not None, + value_fn=lambda data: data.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -283,8 +281,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l1_v is not None, - value_fn=lambda data: data.active_voltage_l1_v, + has_fn=lambda data: data.voltage_l1_v is not None, + value_fn=lambda data: data.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -294,8 +292,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l2_v is not None, - value_fn=lambda data: data.active_voltage_l2_v, + has_fn=lambda data: data.voltage_l2_v is not None, + value_fn=lambda data: data.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -305,8 +303,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l3_v is not None, - value_fn=lambda data: data.active_voltage_l3_v, + has_fn=lambda data: data.voltage_l3_v is not None, + value_fn=lambda data: data.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -314,8 +312,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_a is not None, - value_fn=lambda data: data.active_current_a, + has_fn=lambda data: data.current_a is not None, + value_fn=lambda data: data.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -325,8 +323,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l1_a is not None, - value_fn=lambda data: data.active_current_l1_a, + has_fn=lambda data: data.current_l1_a is not None, + value_fn=lambda data: data.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -336,8 +334,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l2_a is not None, - value_fn=lambda data: data.active_current_l2_a, + has_fn=lambda data: data.current_l2_a is not None, + value_fn=lambda data: data.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -347,8 +345,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l3_a is not None, - value_fn=lambda data: data.active_current_l3_a, + has_fn=lambda data: data.current_l3_a is not None, + value_fn=lambda data: data.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -356,8 +354,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_frequency_hz is not None, - value_fn=lambda data: data.active_frequency_hz, + has_fn=lambda data: data.frequency_hz is not None, + value_fn=lambda data: data.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -365,8 +363,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_va is not None, - value_fn=lambda data: data.active_apparent_power_va, + has_fn=lambda data: data.apparent_power_va is not None, + value_fn=lambda data: data.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -376,8 +374,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l1_va is not None, - value_fn=lambda data: data.active_apparent_power_l1_va, + has_fn=lambda data: data.apparent_power_l1_va is not None, + value_fn=lambda data: data.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -387,8 +385,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l2_va is not None, - value_fn=lambda data: data.active_apparent_power_l2_va, + has_fn=lambda data: data.apparent_power_l2_va is not None, + value_fn=lambda data: data.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -398,8 +396,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l3_va is not None, - value_fn=lambda data: data.active_apparent_power_l3_va, + has_fn=lambda data: data.apparent_power_l3_va is not None, + value_fn=lambda data: data.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -407,8 +405,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_var is not None, - value_fn=lambda data: data.active_reactive_power_var, + has_fn=lambda data: data.reactive_power_var is not None, + value_fn=lambda data: data.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -418,8 +416,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l1_var is not None, - value_fn=lambda data: data.active_reactive_power_l1_var, + has_fn=lambda data: data.reactive_power_l1_var is not None, + value_fn=lambda data: data.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -429,8 +427,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l2_var is not None, - value_fn=lambda data: data.active_reactive_power_l2_var, + has_fn=lambda data: data.reactive_power_l2_var is not None, + value_fn=lambda data: data.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -440,8 +438,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l3_var is not None, - value_fn=lambda data: data.active_reactive_power_l3_var, + has_fn=lambda data: data.reactive_power_l3_var is not None, + value_fn=lambda data: data.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -449,8 +447,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor is not None, - value_fn=lambda data: to_percentage(data.active_power_factor), + has_fn=lambda data: data.power_factor is not None, + value_fn=lambda data: to_percentage(data.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -460,8 +458,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l1), + has_fn=lambda data: data.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -471,8 +469,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l2), + has_fn=lambda data: data.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -482,8 +480,8 @@ def to_percentage(value: float | None) -> float | None: device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l3), + has_fn=lambda data: data.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", @@ -552,8 +550,8 @@ def to_percentage(value: float | None) -> float | None: translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.active_power_average_w is not None, - value_fn=lambda data: data.active_power_average_w, + has_fn=lambda data: data.average_power_15m_w is not None, + value_fn=lambda data: data.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", @@ -624,19 +622,21 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - data = entry.runtime_data.data.data + measurement = entry.runtime_data.data.measurement # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(data) + if description.has_fn(measurement) ] # Initialize external devices - if data.external_devices is not None: - for unique_id, device in data.external_devices.items(): - if description := EXTERNAL_SENSORS.get(device.meter_type): + if measurement.external_devices is not None: + for unique_id, device in measurement.external_devices.items(): + if device.type is not None and ( + description := EXTERNAL_SENSORS.get(device.type) + ): # Add external device entities.append( HomeWizardExternalSensorEntity( @@ -661,13 +661,13 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.data): + if not description.enabled_fn(self.coordinator.data.measurement): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.data) + return self.entity_description.value_fn(self.coordinator.data.measurement) @property def available(self) -> bool: @@ -712,8 +712,8 @@ def native_value(self) -> float | int | str | None: def device(self) -> ExternalDevice | None: """Return ExternalDevice object.""" return ( - self.coordinator.data.data.external_devices[self._device_id] - if self.coordinator.data.data.external_devices is not None + self.coordinator.data.measurement.external_devices[self._device_id] + if self.coordinator.data.measurement.external_devices is not None else None ) diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index aa0af17f5787b8..0878703e4d56e6 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -6,7 +6,8 @@ from dataclasses import dataclass from typing import Any -from homewizard_energy import HomeWizardEnergyV1 +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeWizardConfigEntry -from .const import DeviceResponseEntry from .coordinator import HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler @@ -31,9 +31,9 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" available_fn: Callable[[DeviceResponseEntry], bool] - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] + create_fn: Callable[[DeviceResponseEntry], bool] is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]] + set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] SWITCHES = [ @@ -41,28 +41,28 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): key="power_on", name=None, device_class=SwitchDeviceClass.OUTLET, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None and not data.state.switch_lock, - is_on_fn=lambda data: data.state.power_on if data.state else None, - set_fn=lambda api, active: api.state_set(power_on=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None and not x.state.switch_lock, + is_on_fn=lambda x: x.state.power_on if x.state else None, + set_fn=lambda api, active: api.state(power_on=active), ), HomeWizardSwitchEntityDescription( key="switch_lock", translation_key="switch_lock", entity_category=EntityCategory.CONFIG, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None, - is_on_fn=lambda data: data.state.switch_lock if data.state else None, - set_fn=lambda api, active: api.state_set(switch_lock=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None, + is_on_fn=lambda x: x.state.switch_lock if x.state else None, + set_fn=lambda api, active: api.state(switch_lock=active), ), HomeWizardSwitchEntityDescription( key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, create_fn=lambda _: True, - available_fn=lambda data: data.system is not None, - is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, - set_fn=lambda api, active: api.system_set(cloud_enabled=active), + available_fn=lambda x: x.system is not None, + is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, + set_fn=lambda api, active: api.system(cloud_enabled=active), ), ] @@ -76,7 +76,7 @@ async def async_setup_entry( async_add_entities( HomeWizardSwitchEntity(entry.runtime_data, description) for description in SWITCHES - if description.create_fn(entry.runtime_data) + if description.create_fn(entry.runtime_data.data) ) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 977e6be8afdd2c..10cc2e61fb995e 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -167,15 +167,15 @@ "services": { "send_command": { "name": "Send command", - "description": "Send custom command to a controller", + "description": "Sends a custom command to a controller", "fields": { "command": { "name": "Command", - "description": "Command to send to the controller. This can either be a single command or a list of commands." + "description": "The command to send to the controller. This can either be a single command or a list of commands." }, "controller_id": { "name": "Controller ID", - "description": "The controller to which to send command." + "description": "The controller to which to send the command." } } } diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 7398ada23beab5..1df5eb9601bc8a 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -294,7 +294,7 @@ def hvac_mode(self) -> HVACMode | None: def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if self.hvac_mode == HVACMode.OFF: - return None + return HVACAction.OFF return HW_MODE_TO_HA_HVAC_ACTION.get(self._device.equipment_output_status) @property diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 48cc05984790cf..e9ebdb9da67d64 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -154,6 +154,7 @@ "tag", "timestamp", "vibrate", + "silent", ) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 9ca34af3741349..99877eaf0be27e 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -4,7 +4,6 @@ from collections.abc import Mapping from pathlib import Path -import sys from typing import Final from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE @@ -18,14 +17,7 @@ CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER} RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512) -if sys.version_info >= (3, 13): - # guess_type is soft-deprecated in 3.13 - # for paths and should only be used for - # URLs. guess_file_type should be used - # for paths instead. - _GUESSER = CONTENT_TYPES.guess_file_type -else: - _GUESSER = CONTENT_TYPES.guess_type +_GUESSER = CONTENT_TYPES.guess_file_type class CachingStaticResource(StaticResource): diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 4ca39eaab0c259..f633433c9e4d54 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -22,7 +22,7 @@ class HomeAssistantTCPSite(web.BaseSite): is merged. """ - __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl") + __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") def __init__( self, diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 08fdae50c515db..96e160ece7bff0 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -21,7 +21,6 @@ from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -38,6 +37,14 @@ CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_MANUFACTURER, @@ -262,7 +269,7 @@ def get_device_info( return self.async_create_entry(title=title, data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated config flow.""" @@ -270,13 +277,13 @@ async def async_step_ssdp( assert discovery_info.ssdp_location url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location).hostname}/", ) ) unique_id = discovery_info.upnp.get( - ssdp.ATTR_UPNP_SERIAL, discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] ) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured(updates={CONF_URL: url}) @@ -301,12 +308,12 @@ def _is_supported_device() -> bool: self.context.update( { "title_placeholders": { - CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or "Huawei LTE" } } ) - self.manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self.url = url return await self._async_show_user_form() diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index e044413f296f08..879c7215562048 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -361,7 +361,7 @@ }, "suspend_integration": { "name": "Suspend integration", - "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the resume_integration action to resume.\n.", + "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the 'Resume integration' action to resume.", "fields": { "url": { "name": "[%key:common::config_flow::data::url%]", diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 8d17f810461540..db025922ef83ad 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -13,7 +13,6 @@ import slugify as unicode_slug import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -214,7 +214,7 @@ async def async_step_link( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge. @@ -243,7 +243,7 @@ async def async_step_zeroconf( return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge on HomeKit. diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index debb9710dbdc9b..c53c08c8ac7699 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -7,11 +7,12 @@ import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, HUB_EXCEPTIONS from .util import async_connect_hub @@ -110,7 +111,7 @@ async def _async_validate_or_error( return info, None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self.discovered_ip = discovery_info.ip @@ -118,7 +119,7 @@ async def async_step_dhcp( return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host @@ -128,7 +129,7 @@ async def async_step_zeroconf( return await self.async_step_discovery_confirm() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self.discovered_ip = discovery_info.host diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index da7965250cd80b..a08256fb0b5b04 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -9,16 +9,10 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.util import dt as dt_util from . import api -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -69,8 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) await coordinator.async_config_entry_first_refresh() - available_devices = list(coordinator.data) - cleanup_removed_devices(hass, coordinator.config_entry, available_devices) entry.runtime_data = coordinator entry.async_create_background_task( @@ -86,36 +78,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Handle unload of an entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def cleanup_removed_devices( - hass: HomeAssistant, - config_entry: AutomowerConfigEntry, - available_devices: list[str], -) -> None: - """Cleanup entity and device registry from removed devices.""" - device_reg = dr.async_get(hass) - identifiers = {(DOMAIN, mower_id) for mower_id in available_devices} - for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): - if not set(device.identifiers) & identifiers: - _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) - - -def remove_work_area_entities( - hass: HomeAssistant, - config_entry: AutomowerConfigEntry, - removed_work_areas: set[int], - mower_id: str, -) -> None: - """Remove all unused work area entities for the specified mower.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, config_entry.entry_id - ): - for work_area_id in removed_work_areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{work_area_id}_"): - _LOGGER.info("Deleting: %s", entity_entry.entity_id) - entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 3c23da767978c8..907d34e812a819 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -75,11 +75,16 @@ async def async_setup_entry( ) -> None: """Set up binary sensor platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerBinarySensorEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BINARY_SENSOR_TYPES - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerBinarySensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BINARY_SENSOR_TYPES + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index ce3033254963c7..7e6e581cdf10ed 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -58,12 +58,17 @@ async def async_setup_entry( ) -> None: """Set up button platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerButtonEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BUTTON_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerButtonEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BUTTON_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index f3e82fde5d4729..9e2ea037afb704 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -26,9 +26,14 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerCalendarEntity(mower_id, coordinator) for mower_id in mower_ids + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 57be02e7066182..2921b5ca68ee60 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging from typing import TYPE_CHECKING @@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -47,6 +49,12 @@ def __init__(self, hass: HomeAssistant, api: AutomowerSession) -> None: self.api = api self.ws_connected: bool = False self.reconnect_time = DEFAULT_RECONNECT_TIME + self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] + self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] + self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self._devices_last_update: set[str] = set() + self._zones_last_update: dict[str, set[str]] = {} + self._areas_last_update: dict[str, set[int]] = {} async def _async_update_data(self) -> dict[str, MowerAttributes]: """Subscribe for websocket and poll data from the API.""" @@ -55,12 +63,21 @@ async def _async_update_data(self) -> dict[str, MowerAttributes]: self.api.register_data_callback(self.callback) self.ws_connected = True try: - return await self.api.get_status() + data = await self.api.get_status() except ApiException as err: raise UpdateFailed(err) from err except AuthException as err: raise ConfigEntryAuthFailed(err) from err + self._async_add_remove_devices(data) + for mower_id in data: + if data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones(data) + for mower_id in data: + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + return data + @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" @@ -96,3 +113,136 @@ async def client_listen( self.client_listen(hass, entry, automower_client), "reconnect_task", ) + + def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + """Add new device, remove non-existing device.""" + current_devices = set(data) + + # Skip update if no changes + if current_devices == self._devices_last_update: + return + + # Process removed devices + removed_devices = self._devices_last_update - current_devices + if removed_devices: + _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) + self._remove_device(removed_devices) + + # Process new device + new_devices = current_devices - self._devices_last_update + if new_devices: + _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) + self._add_new_devices(new_devices) + + # Update device state + self._devices_last_update = current_devices + + def _remove_device(self, removed_devices: set[str]) -> None: + """Remove device from the registry.""" + device_registry = dr.async_get(self.hass) + for mower_id in removed_devices: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, str(mower_id))} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + def _add_new_devices(self, new_devices: set[str]) -> None: + """Add new device and trigger callbacks.""" + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) + + def _async_add_remove_stay_out_zones( + self, data: dict[str, MowerAttributes] + ) -> None: + """Add new stay-out zones, remove non-existing stay-out zones.""" + current_zones = { + mower_id: set(mower_data.stay_out_zones.zones) + for mower_id, mower_data in data.items() + if mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + } + + if not self._zones_last_update: + self._zones_last_update = current_zones + return + + if current_zones == self._zones_last_update: + return + + self._zones_last_update = self._update_stay_out_zones(current_zones) + + def _update_stay_out_zones( + self, current_zones: dict[str, set[str]] + ) -> dict[str, set[str]]: + """Update stay-out zones by adding and removing as needed.""" + new_zones = { + mower_id: zones - self._zones_last_update.get(mower_id, set()) + for mower_id, zones in current_zones.items() + } + removed_zones = { + mower_id: self._zones_last_update.get(mower_id, set()) - zones + for mower_id, zones in current_zones.items() + } + + for mower_id, zones in new_zones.items(): + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, set(zones)) + + entity_registry = er.async_get(self.hass) + for mower_id, zones in removed_zones.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for zone in zones: + if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_zones + + def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + """Add new work areas, remove non-existing work areas.""" + current_areas = { + mower_id: set(mower_data.work_areas) + for mower_id, mower_data in data.items() + if mower_data.capabilities.work_areas and mower_data.work_areas is not None + } + + if not self._areas_last_update: + self._areas_last_update = current_areas + return + + if current_areas == self._areas_last_update: + return + + self._areas_last_update = self._update_work_areas(current_areas) + + def _update_work_areas( + self, current_areas: dict[str, set[int]] + ) -> dict[str, set[int]]: + """Update work areas by adding and removing as needed.""" + new_areas = { + mower_id: areas - self._areas_last_update.get(mower_id, set()) + for mower_id, areas in current_areas.items() + } + removed_areas = { + mower_id: self._areas_last_update.get(mower_id, set()) - areas + for mower_id, areas in current_areas.items() + } + + for mower_id, areas in new_areas.items(): + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, set(areas)) + + entity_registry = er.async_get(self.hass) + for mower_id, areas in removed_areas.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for area in areas: + if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_areas diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 520eaceb1d0c51..2fd59b63014fae 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -19,11 +19,16 @@ async def async_setup_entry( ) -> None: """Set up device tracker platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerDeviceTrackerEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.position - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerDeviceTrackerEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.position + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity): diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 9b3ce7dab1afd3..dd75a8b9bc4512 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -53,10 +53,15 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + [AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in mower_ids] + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "override_schedule", diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 02e87a3a772819..1eed2be4575e0a 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2024.12.0"] + "requirements": ["aioautomower==2025.1.0"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index e69b52fab93a9a..d3666494646ff8 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AutomowerConfigEntry, remove_work_area_entities +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerControlEntity, @@ -111,44 +111,47 @@ async def async_setup_entry( ) -> None: """Set up number platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerNumberEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_NUMBER_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add/remove entities as needed.""" - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - current_work_area_set = current_work_areas.setdefault(mower_id, set()) - - new_work_areas = received_work_areas - current_work_area_set - removed_work_areas = current_work_area_set - received_work_areas - - if new_work_areas: - current_work_area_set.update(new_work_areas) - async_add_entities( - WorkAreaNumberEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_NUMBER_TYPES - for work_area_id in new_work_areas + entities: list[NumberEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaNumberEntity( + mower_id, coordinator, description, work_area_id ) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in _work_areas + ) + entities.extend( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) - if removed_work_areas: - remove_work_area_entities(hass, entry, removed_work_areas, mower_id) - current_work_area_set.difference_update(removed_work_areas) + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaNumberEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in work_area_ids + ) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + for mower_id in mower_ids + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + work_area_ids = set(mower_data.work_areas.keys()) + _async_add_new_work_areas(mower_id, work_area_ids) + + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity): diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml index 2287ccb4d4f94f..2fa41c02a4c564 100644 --- a/homeassistant/components/husqvarna_automower/quality_scale.yaml +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: - status: todo - comment: Add devices dynamically + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -70,9 +68,7 @@ rules: status: exempt comment: no configuration possible repair-issues: done - stale-devices: - status: todo - comment: We only remove devices on reload + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 65960e897e48b4..03b1ac02587688 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -33,11 +33,17 @@ async def async_setup_entry( ) -> None: """Set up select platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerSelectEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.headlights - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSelectEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.headlights + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index fb8603623e4f16..a2f4b5f4bab7d0 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -434,44 +434,56 @@ async def async_setup_entry( ) -> None: """Set up sensor platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerSensorEntity(mower_id, coordinator, description) - for mower_id, data in coordinator.data.items() - for description in MOWER_SENSOR_TYPES - if description.exists_fn(data) - ) + entities: list[SensorEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSensorEntity( + mower_id, coordinator, description, work_area_id + ) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in _work_areas + if description.exists_fn(_work_areas[work_area_id]) + ) + entities.extend( + AutomowerSensorEntity(mower_id, coordinator, description) + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) + + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + mower_data = coordinator.data[mower_id] + if mower_data.work_areas is None: + return + + async_add_entities( + WorkAreaSensorEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in work_area_ids + if work_area_id in mower_data.work_areas + and description.exists_fn(mower_data.work_areas[work_area_id]) + ) - def _async_work_area_listener() -> None: - """Listen for new work areas and add sensor entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas( + mower_id, + set(mower_data.work_areas.keys()), ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSensorEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_SENSOR_TYPES - for work_area_id in new_work_areas - if description.exists_fn(_work_areas[work_area_id]) - ) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index d4c91e29f7d1d5..9bd0bb06b3eccf 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -322,7 +322,7 @@ "services": { "override_schedule": { "name": "Override schedule", - "description": "Override the schedule to either mow or park for a duration of time.", + "description": "Lets the mower either mow or park for a given duration, overriding all schedules.", "fields": { "duration": { "name": "Duration", @@ -336,7 +336,7 @@ }, "override_schedule_work_area": { "name": "Override schedule work area", - "description": "Override the schedule of the mower for a duration of time in the selected work area.", + "description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.", "fields": { "duration": { "name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]", diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 352b4c59ba1621..b8004e17066d35 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry @@ -31,82 +30,63 @@ async def async_setup_entry( ) -> None: """Set up switch platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - current_stay_out_zones: dict[str, set[str]] = {} - - async_add_entities( + entities: list[SwitchEntity] = [] + entities.extend( AutomowerScheduleSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add switch entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.stay_out_zones: + _stay_out_zones = coordinator.data[mower_id].stay_out_zones + if _stay_out_zones is not None: + entities.extend( + StayOutZoneSwitchEntity(coordinator, mower_id, stay_out_zone_uid) + for stay_out_zone_uid in _stay_out_zones.zones + ) + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in _work_areas ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) - for work_area_id in new_work_areas - ) - - def _remove_stay_out_zone_entities( - removed_stay_out_zones: set, mower_id: str + async_add_entities(entities) + + def _async_add_new_stay_out_zones( + mower_id: str, stay_out_zone_uids: set[str] ) -> None: - """Remove all unused stay-out zones for all platforms.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, entry.entry_id - ): - for stay_out_zone_uid in removed_stay_out_zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{stay_out_zone_uid}"): - entity_reg.async_remove(entity_entry.entity_id) - - def _async_stay_out_zone_listener() -> None: - """Listen for new stay-out zones and add/remove switch entities if they did not exist.""" - for mower_id in coordinator.data: + async_add_entities( + StayOutZoneSwitchEntity(coordinator, mower_id, zone_uid) + for zone_uid in stay_out_zone_uids + ) + + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in work_area_ids + ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerScheduleSwitchEntity(mower_id, coordinator) + for mower_id in mower_ids + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] if ( - coordinator.data[mower_id].capabilities.stay_out_zones - and (_stay_out_zones := coordinator.data[mower_id].stay_out_zones) - is not None + mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + and mower_data.stay_out_zones.zones is not None ): - received_stay_out_zones = set(_stay_out_zones.zones) - current_stay_out_zones_set = current_stay_out_zones.get(mower_id, set()) - new_stay_out_zones = ( - received_stay_out_zones - current_stay_out_zones_set - ) - removed_stay_out_zones = ( - current_stay_out_zones_set - received_stay_out_zones + _async_add_new_stay_out_zones( + mower_id, set(mower_data.stay_out_zones.zones.keys()) ) - if new_stay_out_zones: - current_stay_out_zones.setdefault(mower_id, set()).update( - new_stay_out_zones - ) - async_add_entities( - StayOutZoneSwitchEntity( - coordinator, mower_id, stay_out_zone_uid - ) - for stay_out_zone_uid in new_stay_out_zones - ) - if removed_stay_out_zones: - _remove_stay_out_zone_entities(removed_stay_out_zones, mower_id) - - coordinator.async_add_listener(_async_work_area_listener) - coordinator.async_add_listener(_async_stay_out_zone_listener) - _async_work_area_listener() - _async_stay_out_zone_listener() + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas(mower_id, set(mower_data.work_areas.keys())) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_zones_callbacks.append(_async_add_new_stay_out_zones) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity): diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 50f803c07dc0de..de45eb061d5557 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.12.0"] + "requirements": ["pydrawise==2025.1.0"] } diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index b2b7dbdf531104..045fbd986ccee6 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -12,7 +12,6 @@ from hyperion import client, const import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -30,6 +29,7 @@ ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client from .const import ( @@ -155,7 +155,7 @@ async def async_step_reauth( return await self._advance_to_auth_step_if_necessary(hyperion_client) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initiated by SSDP.""" # Sample data provided by SSDP: { @@ -210,7 +210,7 @@ async def async_step_ssdp( except ValueError: self._data[CONF_PORT] = const.DEFAULT_PORT_JSON - if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)): + if not (hyperion_id := discovery_info.upnp.get(ATTR_UPNP_SERIAL)): return self.async_abort(reason="no_id") # For discovery mechanisms, we set the unique_id as early as possible to diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 01682648277933..ea7bc9e39fad07 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -18,7 +18,7 @@ } }, "create_token": { - "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown id is \"{auth_id}\"", + "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown ID is \"{auth_id}\"", "title": "Automatically create new authentication token" }, "create_token_external": { @@ -40,7 +40,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI", "auth_new_token_not_work_error": "Failed to authenticate using newly created token", - "no_id": "The Hyperion Ambilight instance did not report its id", + "no_id": "The Hyperion Ambilight instance did not report its ID", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 22c711e919ab4c..adc96043d66505 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -56,7 +56,7 @@ }, "play_sound": { "name": "Play sound", - "description": "Plays sound on an Apple device.", + "description": "Plays the Lost device sound on an Apple device.", "fields": { "account": { "name": "Account", @@ -64,7 +64,7 @@ }, "device_name": { "name": "Device name", - "description": "The name of the Apple device to play a sound." + "description": "The name of the Apple device to play the sound." } } }, @@ -92,7 +92,7 @@ }, "lost_device": { "name": "Lost device", - "description": "Makes an Apple device in lost state.", + "description": "Puts an Apple device in lost state.", "fields": { "account": { "name": "Account", diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index f73c307986706f..f90b2ee943cd03 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -43,7 +43,7 @@ def __init__( self.suggested_area = product.get("group") if "id" in product: product_id = product["id"] - self.device_id = f"{controller_id}_{product_id }" + self.device_id = f"{controller_id}_{product_id}" # this will name the device the same way as the IHC visual application: Product name + position self.device_name = product["name"] if self.ihc_position: diff --git a/homeassistant/components/ihc/strings.json b/homeassistant/components/ihc/strings.json index af2152a88bb121..04daef83c9d010 100644 --- a/homeassistant/components/ihc/strings.json +++ b/homeassistant/components/ihc/strings.json @@ -6,7 +6,7 @@ "fields": { "controller_id": { "name": "Controller ID", - "description": "If you have multiple controller, this is the index of you controller\nstarting with 0.\n." + "description": "If you have multiple controllers, this is the index of your controller, starting with 0." }, "ihc_id": { "name": "IHC ID", diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index b058a3d50f4a80..a3370de94ca158 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==1.1.0"] + "requirements": ["aioimaplib==2.0.0"] } diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index caf4e058e06198..eb12e1a2bb467f 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -9,16 +9,18 @@ from imgw_pib import ImgwPib from imgw_pib.exceptions import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION_ID +from .const import CONF_STATION_ID, DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -42,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b try: imgwpib = await ImgwPib.create( - client_session, hydrological_station_id=station_id + client_session, + hydrological_station_id=station_id, + hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: raise ConfigEntryNotReady from err @@ -50,6 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() + # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning", "flood_alarm"): + if entity_id := entity_reg.async_get_entity_id( + BINARY_SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + entry.runtime_data = ImgwPibData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/imgw_pib/binary_sensor.py b/homeassistant/components/imgw_pib/binary_sensor.py deleted file mode 100644 index 1c4cc738f8fd06..00000000000000 --- a/homeassistant/components/imgw_pib/binary_sensor.py +++ /dev/null @@ -1,82 +0,0 @@ -"""IMGW-PIB binary sensor platform.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - -from imgw_pib.model import HydrologicalData - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import ImgwPibConfigEntry -from .coordinator import ImgwPibDataUpdateCoordinator -from .entity import ImgwPibEntity - -PARALLEL_UPDATES = 1 - - -@dataclass(frozen=True, kw_only=True) -class ImgwPibBinarySensorEntityDescription(BinarySensorEntityDescription): - """IMGW-PIB sensor entity description.""" - - value: Callable[[HydrologicalData], bool | None] - - -BINARY_SENSOR_TYPES: tuple[ImgwPibBinarySensorEntityDescription, ...] = ( - ImgwPibBinarySensorEntityDescription( - key="flood_warning", - translation_key="flood_warning", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_warning, - ), - ImgwPibBinarySensorEntityDescription( - key="flood_alarm", - translation_key="flood_alarm", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_alarm, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ImgwPibConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add a IMGW-PIB binary sensor entity from a config_entry.""" - coordinator = entry.runtime_data.coordinator - - async_add_entities( - ImgwPibBinarySensorEntity(coordinator, description) - for description in BINARY_SENSOR_TYPES - if getattr(coordinator.data, description.key) is not None - ) - - -class ImgwPibBinarySensorEntity(ImgwPibEntity, BinarySensorEntity): - """Define IMGW-PIB binary sensor entity.""" - - entity_description: ImgwPibBinarySensorEntityDescription - - def __init__( - self, - coordinator: ImgwPibDataUpdateCoordinator, - description: ImgwPibBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{coordinator.station_id}_{description.key}" - self.entity_description = description - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index bf8608ae21b085..29aa19a4b56e42 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,26 +1,6 @@ { "entity": { - "binary_sensor": { - "flood_warning": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - }, - "flood_alarm": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - } - }, "sensor": { - "flood_warning_level": { - "default": "mdi:alert-outline" - }, - "flood_alarm_level": { - "default": "mdi:alert" - }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index ce3bc14d37b9f0..0ecc1b4b7d0bd4 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.7"] + "requirements": ["imgw_pib==1.0.9"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index f000222b31bd28..15043af2015fec 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -8,17 +8,20 @@ from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( + DOMAIN as SENSOR_PLATFORM, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import ImgwPibConfigEntry +from .const import DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity @@ -33,26 +36,6 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( - ImgwPibSensorEntityDescription( - key="flood_alarm_level", - translation_key="flood_alarm_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_alarm_level.value, - ), - ImgwPibSensorEntityDescription( - key="flood_warning_level", - translation_key="flood_warning_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_warning_level.value, - ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", @@ -82,6 +65,14 @@ async def async_setup_entry( """Add a IMGW-PIB sensor entity from a config_entry.""" coordinator = entry.runtime_data.coordinator + # Remove entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning_level", "flood_alarm_level"): + if entity_id := entity_reg.async_get_entity_id( + SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + async_add_entities( ImgwPibSensorEntity(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 6bc337d5720633..9a17dcf70878fe 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -17,21 +17,7 @@ } }, "entity": { - "binary_sensor": { - "flood_alarm": { - "name": "Flood alarm" - }, - "flood_warning": { - "name": "Flood warning" - } - }, "sensor": { - "flood_alarm_level": { - "name": "Flood alarm level" - }, - "flood_warning_level": { - "name": "Flood warning level" - }, "water_level": { "name": "Water level" }, diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 05dd1de449a7fe..22f2bf3623c659 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -126,15 +126,23 @@ def _abort_if_provisioned(self) -> None: ) except improv_ble_errors.InvalidCommand as err: _LOGGER.warning( - "Aborting improv flow, device %s sent invalid improv data: '%s'", - self._discovery_info.address, + ( + "Received invalid improv via BLE data '%s' from device with " + "bluetooth address '%s'; if the device is a self-configured " + "ESPHome device, either correct or disable the 'esp32_improv' " + "configuration; if it's a commercial device, contact the vendor" + ), service_data[SERVICE_DATA_UUID].hex(), + self._discovery_info.address, ) raise AbortFlow("invalid_improv_data") from err if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device %s is already provisioned: %s", + ( + "Aborting improv flow, device with bluetooth address '%s' is " + "already provisioned: %s" + ), self._discovery_info.address, improv_service_data.state, ) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 4b6a6a5fcc3434..e6775f5baca691 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -25,7 +25,7 @@ type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: data = await async_connect_gateway(hass, dict(entry.data)) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 45da990d44fa97..c4a23946bb2786 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -13,6 +13,7 @@ BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,6 +30,7 @@ class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): value_key: str extra_state_attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( @@ -40,24 +42,28 @@ class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): extra_state_attributes_fn=lambda status: { "fault_code": status["fault_code"] or "none", }, + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_pumping", translation_key="is_pumping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_pumping", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_burning", translation_key="is_burning", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_burning", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_tapping", translation_key="is_tapping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_tapping", + entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index caebcfdb23b8b2..756e14fc545fe9 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,13 +12,13 @@ HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import InComfortConfigEntry -from .const import DOMAIN +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortDataCoordinator from .entity import IncomfortEntity @@ -32,15 +32,19 @@ async def async_setup_entry( ) -> None: """Set up InComfort/InTouch climate devices.""" incomfort_coordinator = entry.runtime_data + legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False) heaters = incomfort_coordinator.data.heaters async_add_entities( - InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms + InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status) + for h in heaters + for r in h.rooms ) class InComfortClimate(IncomfortEntity, ClimateEntity): """Representation of an InComfort/InTouch climate device.""" + _attr_entity_category = EntityCategory.CONFIG _attr_min_temp = 5.0 _attr_max_temp = 30.0 _attr_name = None @@ -54,12 +58,14 @@ def __init__( coordinator: InComfortDataCoordinator, heater: InComfortHeater, room: InComfortRoom, + legacy_setpoint_status: bool, ) -> None: """Initialize the climate device.""" super().__init__(coordinator) self._heater = heater self._room = room + self._legacy_setpoint_status = legacy_setpoint_status self._attr_unique_id = f"{heater.serial_no}_{room.room_no}" self._attr_device_info = DeviceInfo( @@ -91,9 +97,11 @@ def target_temperature(self) -> float | None: As we set the override, we report back the override. The actual set point is is returned at a later time. - Some older thermostats return 0.0 as override, in that case we fallback to - the actual setpoint. + Some older thermostats do not clear the override setting in that case, in that case + we fallback to the returning actual setpoint. """ + if self._legacy_setpoint_status: + return self._room.setpoint return self._room.override or self._room.setpoint async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index f4838a9771d5b8..3db8e40f9f407c 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -1,21 +1,32 @@ """Config flow support for Intergas InComfort integration.""" +from __future__ import annotations + +from collections.abc import Mapping from typing import Any from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, ) -from .const import DOMAIN +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import async_connect_gateway TITLE = "Intergas InComfort/Intouch Lan2RF gateway" @@ -34,6 +45,23 @@ } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector( + BooleanSelectorConfig() + ) + } +) + ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { 401: (CONF_PASSWORD, "auth_error"), 404: ("base", "not_found"), @@ -66,18 +94,102 @@ async def async_try_connect_gateway( class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow to set up an Intergas InComfort boyler and thermostats.""" + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> InComfortOptionsFlowHandler: + """Get the options flow for this handler.""" + return InComfortOptionsFlowHandler() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] | None = None + data_schema: vol.Schema = CONFIG_SCHEMA + if is_reconfigure := (self.source == SOURCE_RECONFIGURE): + reconfigure_entry = self._get_reconfigure_entry() + data_schema = self.add_suggested_values_to_schema( + data_schema, reconfigure_entry.data + ) if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) if ( - errors := await async_try_connect_gateway(self.hass, user_input) + errors := await async_try_connect_gateway( + self.hass, + (reconfigure_entry.data | user_input) + if is_reconfigure + else user_input, + ) ) is None: + if is_reconfigure: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) return self.async_create_entry(title=TITLE, data=user_input) + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication and confirmation.""" + errors: dict[str, str] | None = None + + if user_input: + password: str = user_input[CONF_PASSWORD] + + reauth_entry = self._get_reauth_entry() + errors = await async_try_connect_gateway( + self.hass, reauth_entry.data | {CONF_PASSWORD: password} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration flow.""" + return await self.async_step_user() + + +class InComfortOptionsFlowHandler(OptionsFlow): + """Handle InComfort Lan2RF gateway options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] | None = None + if user_input is not None: + new_options: dict[str, Any] = self.config_entry.options | user_input + self.hass.config_entries.async_update_entry( + self.config_entry, options=new_options + ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + return self.async_create_entry(data=new_options) + + data_schema = self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ) return self.async_show_form( - step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + step_id="init", + data_schema=data_schema, + errors=errors, ) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py index 721dd8591b0a05..b3b9312acd6acd 100644 --- a/homeassistant/components/incomfort/const.py +++ b/homeassistant/components/incomfort/const.py @@ -1,3 +1,5 @@ """Constants for Intergas InComfort integration.""" DOMAIN = "incomfort" + +CONF_LEGACY_SETPOINT_STATUS = "legacy_setpoint_status" diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index a5c8da0c208466..20cc8e7cc69131 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -66,10 +66,10 @@ async def _async_update_data(self) -> InComfortData: for heater in self.incomfort_data.heaters: await heater.update() except TimeoutError as exc: - raise UpdateFailed from exc + raise UpdateFailed("Timeout error") from exc except IncomfortError as exc: if isinstance(exc.message, ClientResponseError): if exc.message.status == 401: raise ConfigEntryError("Incorrect credentials") from exc - raise UpdateFailed from exc + raise UpdateFailed(exc.message) from exc return self.incomfort_data diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py new file mode 100644 index 00000000000000..1f21dfed8b31e8 --- /dev/null +++ b/homeassistant/components/incomfort/diagnostics.py @@ -0,0 +1,45 @@ +"""Diagnostics support for InComfort integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback + +from . import InComfortConfigEntry + +REDACT_CONFIG = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: InComfortConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: InComfortConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG) + coordinator = entry.runtime_data + + nr_heaters = len(coordinator.incomfort_data.heaters) + status: dict[str, Any] = { + f"heater_{n}": coordinator.incomfort_data.heaters[n].status + for n in range(nr_heaters) + } + for n in range(nr_heaters): + status[f"heater_{n}"]["rooms"] = { + n: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) + for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) + } + return { + "config": redacted_config, + "gateway": status, + } diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index 33037a78edf425..dd662b411dd3a1 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -26,4 +26,5 @@ def __init__(self, coordinator: InComfortDataCoordinator, heater: Heater) -> Non identifiers={(DOMAIN, heater.serial_no)}, manufacturer="Intergas", name="Boiler", + serial_number=heater.serial_no, ) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e3cc52fb3a7dba..e9697a0036f1b2 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -13,7 +13,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfPressure, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -31,6 +31,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): value_key: str extra_key: str | None = None + entity_category = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( @@ -40,6 +41,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, value_key="pressure", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="cv_temp", @@ -48,6 +50,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_pumping", value_key="heater_temp", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="tap_temp", @@ -57,6 +60,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_tapping", value_key="tap_temp", + entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index a2bb874142be08..8bcfa4ce5e1c86 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -13,44 +13,41 @@ "username": "The username to log into the gateway. This is `admin` in most cases.", "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Correct the gateway password." + }, + "description": "Re-authenticate to the gateway." } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No Lan2RF gateway found.", "timeout_error": "Time out when connection to Lan2RF gateway.", "unknown": "Unknown error when connection to Lan2RF gateway." - }, - "error": { - "auth_error": "[%key:component::incomfort::config::abort::auth_error%]", - "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]", - "not_found": "[%key:component::incomfort::config::abort::not_found%]", - "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]", - "unknown": "[%key:component::incomfort::config::abort::unknown%]" } }, - "issues": { - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} 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." - }, - "deprecated_yaml_import_issue_auth_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} 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." - }, - "deprecated_yaml_import_issue_no_heaters": { - "title": "YAML import failed because no heaters were found", - "description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} 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." - }, - "deprecated_yaml_import_issue_not_found": { - "title": "YAML import failed because no gateway was found", - "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} 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." - }, - "deprecated_yaml_import_issue_timeout_error": { - "title": "YAML import failed because of timeout issues", - "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} 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." + "options": { + "step": { + "init": { + "title": "Intergas InComfort Lan2RF Gateway options", + "data": { + "legacy_setpoint_status": "Legacy setpoint handling" + }, + "data_description": { + "legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated." + } + } } }, "entity": { diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 5b676b3b7ff9cc..0ab4a6a06b80be 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -8,7 +8,7 @@ from incomfortclient import Heater as InComfortHeater from homeassistant.components.water_heater import WaterHeaterEntity -from homeassistant.const import UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +37,7 @@ async def async_setup_entry( class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): """Representation of an InComfort/Intouch water_heater device.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_min_temp = 30.0 _attr_max_temp = 80.0 _attr_name = None diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 143a9e2a5e211f..54756397211847 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -7,7 +7,7 @@ from pyinsteon import async_connect -from homeassistant.components import dhcp, usb +from homeassistant.components import usb from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +15,8 @@ ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import CONF_HUB_VERSION, DOMAIN from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema @@ -129,9 +131,7 @@ async def _async_setup_hub( step_id=step_id, data_schema=data_schema, errors=errors ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB discovery.""" self._device_path = discovery_info.device self._device_name = usb.human_readable_device_name( @@ -162,7 +162,7 @@ async def async_step_confirm_usb( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a DHCP discovery.""" self.discovered_conf = {CONF_HOST: discovery_info.ip} diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 4df997ac939395..4a8aadb70db8a3 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -164,7 +164,7 @@ }, "x10_all_units_off": { "name": "X10 all units off", - "description": "[%key:component::insteon::services::add_all_link::description%]", + "description": "Sends X10 'All units off' command.", "fields": { "housecode": { "name": "Housecode", @@ -174,7 +174,7 @@ }, "x10_all_lights_on": { "name": "X10 all lights on", - "description": "Sends X10 All Lights On command.", + "description": "Sends X10 'All lights on' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", @@ -184,7 +184,7 @@ }, "x10_all_lights_off": { "name": "X10 all lights off", - "description": "Sends X10 All Lights Off command.", + "description": "Sends X10 'All lights off' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 7609398673b38f..ce78f1a6fa31f4 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -149,7 +149,7 @@ async def _async_wait_for_initialization( while ( fireplace.data.ipv4_address == "127.0.0.1" and fireplace.data.serial == "unset" ): - LOGGER.debug(f"Waiting for fireplace to initialize [{fireplace.read_mode}]") + LOGGER.debug("Waiting for fireplace to initialize [%s]", fireplace.read_mode) await asyncio.sleep(INIT_WAIT_TIME_SECONDS) diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index a6b63f3b3e8aeb..35c3bc09010e51 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -13,7 +13,6 @@ from intellifire4py.model import IntelliFireCommonFireplaceData import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -22,6 +21,7 @@ CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( API_MODE_LOCAL, @@ -145,13 +145,13 @@ async def async_step_pick_cloud_device( """ errors: dict[str, str] = {} LOGGER.debug( - f"STEP: pick_cloud_device: {user_input} - DHCP_MODE[{self._dhcp_mode}" + "STEP: pick_cloud_device: %s - DHCP_MODE[%s]", user_input, self._dhcp_mode ) if self._dhcp_mode or user_input is not None: if self._dhcp_mode: serial = self._dhcp_discovered_serial - LOGGER.debug(f"DHCP Mode detected for serial [{serial}]") + LOGGER.debug("DHCP Mode detected for serial [%s]", serial) if user_input is not None: serial = user_input[CONF_SERIAL] diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 71ef40ad369362..a1451f8fcca4dd 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -65,11 +65,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) __all__ = [ - "async_register_timer_handler", - "async_device_supports_timers", - "TimerInfo", - "TimerEventType", "DOMAIN", + "TimerEventType", + "TimerInfo", + "async_device_supports_timers", + "async_register_timer_handler", ] ONOFF_DEVICE_CLASSES = { diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 5f2cb98646b69f..2a921cdbb0485e 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -4,8 +4,9 @@ import asyncio from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from dataclasses import asdict, dataclass import logging +from typing import Any from pyipma.api import IPMA_API from pyipma.location import Location @@ -28,23 +29,41 @@ class IPMASensorEntityDescription(SensorEntityDescription): """Describes a IPMA sensor entity.""" - value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] + value_fn: Callable[ + [Location, IPMA_API], Coroutine[Location, IPMA_API, tuple[Any, dict[str, Any]]] + ] -async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) if fire_risk: - return fire_risk.rcm - return None + return fire_risk.rcm, {} + return None, {} -async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_uvi( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve UV.""" uv_risk: UV = await location.uv_risk(api) if uv_risk: - return round(uv_risk.iUv) - return None + return round(uv_risk.iUv), {} + return None, {} + + +async def async_retrieve_warning( + location: Location, api: IPMA_API +) -> tuple[Any, dict[str, str]]: + """Retrieve Warning.""" + warnings = await location.warnings(api) + if len(warnings): + return warnings[0].awarenessLevelID, { + k: str(v) for k, v in asdict(warnings[0]).items() + } + return "green", {} SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( @@ -58,6 +77,11 @@ async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: translation_key="uv_index", value_fn=async_retrieve_uvi, ), + IPMASensorEntityDescription( + key="alert", + translation_key="weather_alert", + value_fn=async_retrieve_warning, + ), ) @@ -94,6 +118,8 @@ def __init__( async def async_update(self) -> None: """Update sensors.""" async with asyncio.timeout(10): - self._attr_native_value = await self.entity_description.value_fn( + state, attrs = await self.entity_description.value_fn( self._location, self._api ) + self._attr_native_value = state + self._attr_extra_state_attributes = attrs diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index ea5e5ff475927a..ff9c23dd7ca5d6 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -31,6 +31,15 @@ }, "uv_index": { "name": "UV index" + }, + "weather_alert": { + "name": "Weather Alert", + "state": { + "red": "Red", + "yellow": "Yellow", + "orange": "Orange", + "green": "Green" + } } } } diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index ecd4d1af9f6f55..4d0c43242e402b 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -16,7 +16,6 @@ ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -28,6 +27,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN @@ -103,7 +103,7 @@ async def async_step_user( return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 4a308d02b3d133..a738036b3eefa5 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.2.1", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.2.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 339cbdcca28782..080fee20762ff8 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import timedelta +from enum import Enum import logging from typing import cast @@ -151,7 +152,11 @@ async def _async_update_data(self) -> SettingsDataResponse: return self.data or SettingsDataResponse() - async def write(self, characteristic: CharSetting, value: bool) -> None: + async def write( + self, + characteristic: CharSetting, + value: bool | Enum | float, + ) -> None: """Write value to the settings characteristic.""" try: diff --git a/homeassistant/components/iron_os/diagnostics.py b/homeassistant/components/iron_os/diagnostics.py new file mode 100644 index 00000000000000..e9545c24dec672 --- /dev/null +++ b/homeassistant/components/iron_os/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics platform for IronOS integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import IronOSConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IronOSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry_data": { + CONF_ADDRESS: config_entry.unique_id, + }, + "device_info": config_entry.runtime_data.live_data.device_info, + "live_data": config_entry.runtime_data.live_data.data, + "settings_data": config_entry.runtime_data.settings.data, + } diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index b05e72565b95aa..6410c561b9d98d 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -102,6 +102,9 @@ }, "logo_duration": { "default": "mdi:clock-digital" + }, + "usb_pd_mode": { + "default": "mdi:meter-electric-outline" } }, "sensor": { @@ -180,9 +183,6 @@ "invert_buttons": { "default": "mdi:plus-minus-variant" }, - "usb_pd_mode": { - "default": "mdi:meter-electric-outline" - }, "idle_screen_details": { "default": "mdi:card-bulleted-outline", "state": { diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 5ba2a90507f809..462e75c5b6e726 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil"], - "requirements": ["pynecil==3.0.1"] + "requirements": ["pynecil==4.0.1"] } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 583844223ddab1..518c11372c4ab1 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,12 +6,7 @@ from dataclasses import dataclass from enum import StrEnum -from pynecil import ( - CharSetting, - CommunicationError, - LiveDataResponse, - SettingsDataResponse, -) +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse from homeassistant.components.number import ( DEFAULT_MAX_VALUE, @@ -28,11 +23,10 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN, MAX_TEMP, MIN_TEMP +from .const import MAX_TEMP, MIN_TEMP from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -188,8 +182,8 @@ def multiply(value: float | None, multiplier: float) -> float | None: characteristic=CharSetting.POWER_LIMIT, mode=NumberMode.BOX, native_min_value=0, - native_max_value=12, - native_step=0.1, + native_max_value=120, + native_step=5, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfPower.WATT, entity_registry_enabled_default=False, @@ -363,16 +357,8 @@ async def async_set_native_value(self, value: float) -> None: """Update the current value.""" if raw_value_fn := self.entity_description.raw_value_fn: value = raw_value_fn(value) - try: - await self.coordinator.device.write( - self.entity_description.characteristic, value - ) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - await self.settings.async_request_refresh() + + await self.settings.write(self.entity_description.characteristic, value) @property def native_value(self) -> float | int | None: diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index fd89b80d782019..c80b8b5adfe8b4 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -31,9 +31,7 @@ rules: docs-configuration-parameters: status: exempt comment: Integration has no options flow - docs-installation-parameters: - status: todo - comment: Needs bluetooth address as parameter + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -45,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Device is not connected to an ip network. Other information from discovery is immutable and does not require updating. diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index 10d8a6fcef5719..e9c7f81c2083f3 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -5,30 +5,27 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum, StrEnum -from typing import Any from pynecil import ( AnimationSpeed, AutostartMode, BatteryType, CharSetting, - CommunicationError, LockingMode, LogoDuration, ScreenOrientationMode, ScrollSpeed, SettingsDataResponse, TempUnit, + USBPDMode, ) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -41,7 +38,7 @@ class IronOSSelectEntityDescription(SelectEntityDescription): value_fn: Callable[[SettingsDataResponse], str | None] characteristic: CharSetting - raw_value_fn: Callable[[str], Any] | None = None + raw_value_fn: Callable[[str], Enum] class PinecilSelect(StrEnum): @@ -55,6 +52,7 @@ class PinecilSelect(StrEnum): DESC_SCROLL_SPEED = "desc_scroll_speed" LOCKING_MODE = "locking_mode" LOGO_DURATION = "logo_duration" + USB_PD_MODE = "usb_pd_mode" def enum_to_str(enum: Enum | None) -> str | None: @@ -140,6 +138,16 @@ def enum_to_str(enum: Enum | None) -> str | None: entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), + IronOSSelectEntityDescription( + key=PinecilSelect.USB_PD_MODE, + translation_key=PinecilSelect.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")), + raw_value_fn=lambda value: USBPDMode[value.upper()], + options=["off", "on"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), ) @@ -181,18 +189,10 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if raw_value_fn := self.entity_description.raw_value_fn: - value = raw_value_fn(option) - try: - await self.coordinator.device.write( - self.entity_description.characteristic, value - ) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - await self.settings.async_request_refresh() + await self.settings.write( + self.entity_description.characteristic, + self.entity_description.raw_value_fn(option), + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index e9912add8260b6..60168699427fb7 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -136,8 +136,8 @@ "temp_unit": { "name": "Temperature display unit", "state": { - "celsius": "Celsius (C°)", - "fahrenheit": "Fahrenheit (F°)" + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)" } }, "desc_scroll_speed": { @@ -166,6 +166,13 @@ "seconds_5": "5 second", "loop": "Loop" } + }, + "usb_pd_mode": { + "name": "Power Delivery 3.1 EPR", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "sensor": { @@ -244,9 +251,6 @@ }, "calibrate_cjc": { "name": "Calibrate CJC" - }, - "usb_pd_mode": { - "name": "Power Delivery 3.1 EPR" } } }, diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index 4e14b240ffb693..d88e8cfdcb52f2 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -39,7 +39,6 @@ class IronOSSwitch(StrEnum): INVERT_BUTTONS = "invert_buttons" DISPLAY_INVERT = "display_invert" CALIBRATE_CJC = "calibrate_cjc" - USB_PD_MODE = "usb_pd_mode" SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( @@ -95,14 +94,6 @@ class IronOSSwitch(StrEnum): entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - IronOSSwitchEntityDescription( - key=IronOSSwitch.USB_PD_MODE, - translation_key=IronOSSwitch.USB_PD_MODE, - characteristic=CharSetting.USB_PD_MODE, - is_on_fn=lambda x: x.get("usb_pd_mode"), - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - ), ) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index c11c43070df7bb..1a3b2109d0c532 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -66,7 +66,7 @@ async def async_step_user( else: if TYPE_CHECKING: assert info - title = f"{info["firstName"]} {info["lastName"]}".strip() + title = f"{info['firstName']} {info['lastName']}".strip() await self.async_set_unique_id(info["activeConsumptionUnit"]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index eb06fabe37361b..e96ac103741372 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -184,12 +184,12 @@ def __init__( self.consumption_unit = consumption_unit self.entity_description = entity_description self._attr_unique_id = f"{consumption_unit}_{entity_description.key}" + address = coordinator.details[consumption_unit]["address"] self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, manufacturer="ista SE", model="ista EcoTrend", - name=f"{coordinator.details[consumption_unit]["address"]["street"]} " - f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(), + name=f"{address['street']} {address['houseNumber']}".strip(), configuration_url="https://ecotrend.ista.de/", identifiers={(DOMAIN, consumption_unit)}, ) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 3575fa99a55e7d..b44096e2ccd2ae 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -14,7 +14,6 @@ from pyisy.connection import Connection import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -27,6 +26,12 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_IGNORE_STRING, @@ -209,7 +214,7 @@ async def _async_set_unique_id_or_update( raise AbortFlow("already_configured") async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX device via dhcp.""" friendly_name = discovery_info.hostname @@ -232,14 +237,14 @@ async def async_step_dhcp( return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX Device.""" - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] url = discovery_info.ssdp_location assert isinstance(url, str) parsed_url = urlparse(url) - mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + mac = discovery_info.upnp[ATTR_UPNP_UDN] mac = mac.removeprefix(UDN_UUID_PREFIX) url = url.removesuffix(ISY_URL_POSTFIX) diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 41e5899504d0be..8befcf024d16ec 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -45,7 +45,7 @@ def time_string(i: int) -> str: """Return a formatted ramp rate time string.""" if i >= 60: - return f"{(float(i)/60):.1f} {UnitOfTime.MINUTES}" + return f"{(float(i) / 60):.1f} {UnitOfTime.MINUTES}" return f"{i} {UnitOfTime.SECONDS}" diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py index e4a562dc00b038..031709db9f24f7 100644 --- a/homeassistant/components/kaleidescape/config_flow.py +++ b/homeassistant/components/kaleidescape/config_flow.py @@ -7,9 +7,9 @@ import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import KaleidescapeDeviceInfo, UnsupportedError, validate_host from .const import DEFAULT_HOST, DOMAIN, NAME as KALEIDESCAPE_NAME @@ -61,11 +61,11 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovered device.""" host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index d11fedac38556f..3dc4c8b1b778c2 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,7 +8,6 @@ from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,11 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -105,23 +109,23 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" - friendly_name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + friendly_name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") # Filter out items not having "keenetic" in their name if "keenetic" not in friendly_name.lower(): return self.async_abort(reason="not_keenetic_ndms2") # Filters out items having no/empty UDN - if not discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): + if not discovery_info.upnp.get(ATTR_UPNP_UDN): return self.async_abort(reason="no_udn") # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index 1bbce2ff35dae9..db331fe6874ada 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], "quality_scale": "legacy", - "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.5"] } diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json index c8aa644333a80a..56fdeaa67049b4 100644 --- a/homeassistant/components/kef/strings.json +++ b/homeassistant/components/kef/strings.json @@ -39,7 +39,7 @@ "description": "Sets the \"Desk mode\" slider of the speaker in dB.", "fields": { "db_value": { - "name": "DB value", + "name": "dB value", "description": "Value of the slider." } } @@ -75,8 +75,8 @@ } }, "set_low_hz": { - "name": "Sets low Hertz", - "description": "Set the \"Sub out low-pass frequency\" slider of the speaker in Hz.", + "name": "Set low Hertz", + "description": "Sets the \"Sub out low-pass frequency\" slider of the speaker in Hz.", "fields": { "hz_value": { "name": "[%key:component::kef::services::set_high_hz::fields::hz_value::name%]", @@ -85,8 +85,8 @@ } }, "set_sub_db": { - "name": "Sets subwoofer dB", - "description": "Set the \"Sub gain\" slider of the speaker in dB.", + "name": "Set subwoofer dB", + "description": "Sets the \"Sub gain\" slider of the speaker in dB.", "fields": { "db_value": { "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index 217ce3cc923616..821fbc410f7246 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -34,7 +34,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: MicroBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["local_name"]} {short_address(discovery.address)}' + return f"{discovery.data['local_name']} {short_address(discovery.address)}" class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index edb9cc620082a1..fa3439b02f4025 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol from xknx import XKNX from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException @@ -91,7 +92,7 @@ WeatherSchema, ) from .services import register_knx_services -from .storage.config_store import KNXConfigStore +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -226,6 +227,8 @@ def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None: if knxkeys_filename is not None: with contextlib.suppress(FileNotFoundError): (storage_dir / knxkeys_filename).unlink() + with contextlib.suppress(FileNotFoundError): + (storage_dir / CONFIG_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): (storage_dir / PROJECT_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): @@ -271,11 +274,18 @@ def __init__( self.project = KNXProject(hass=hass, entry=entry) self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) self.xknx = XKNX( address_format=self.project.get_address_format(), connection_config=self.connection_config(), rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + state_updater=default_state_updater, ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index a946ded035948a..3ef35479c4e79e 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -114,7 +114,7 @@ class KNXConfigEntryData(TypedDict, total=False): backbone_key: str | None # not required sync_latency_tolerance: int | None # not required # OptionsFlow only - state_updater: bool + state_updater: bool # default state updater: True -> expire 60; False -> init rate_limit: int # Integration only (not forwarded to xknx) telegram_log_size: int # not required diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 82bee48ba69a56..6585b848d8a06d 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -88,7 +88,7 @@ def __init__( self._remove_listener: Callable[[], None] | None = None self.device: ExposeSensor = ExposeSensor( xknx=self.xknx, - name=f"{self.entity_id}__{self.expose_attribute or "state"}", + name=f"{self.entity_id}__{self.expose_attribute or 'state'}", group_address=config[KNX_ADDRESS], respond_to_read=config[CONF_RESPOND_TO_READ], value_type=self.expose_type, diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8d18f11c79801f..73a61be68eeb11 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.12.26.233449" + "knx-frontend==2025.1.18.164225" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 80ff1105e155de..dadc8e84796d23 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -3,9 +3,9 @@ "step": { "connection_type": { "title": "KNX connection", - "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.) \n\n 'Tunneling' will connect to a specific KNX IP interface over a tunnel. \n\n 'Routing' will use Multicast to communicate with KNX IP routers.", + "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", "data": { - "connection_type": "KNX Connection Type" + "connection_type": "KNX connection type" }, "data_description": { "connection_type": "Please select the connection type you want to use for your KNX connection." @@ -33,7 +33,7 @@ "title": "Tunnel settings", "description": "Please enter the connection information of your tunneling device.", "data": { - "tunneling_type": "KNX Tunneling Type", + "tunneling_type": "KNX tunneling type", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "route_back": "Route back / NAT mode", @@ -48,11 +48,11 @@ } }, "secure_key_source_menu_tunnel": { - "title": "KNX IP-Secure", + "title": "KNX IP Secure", "description": "How do you want to configure KNX/IP Secure?", "menu_options": { - "secure_knxkeys": "Use a `.knxkeys` file providing IP secure keys", - "secure_tunnel_manual": "Configure IP secure credentials manually" + "secure_knxkeys": "Use a `.knxkeys` file providing IP Secure keys", + "secure_tunnel_manual": "Configure IP Secure credentials manually" } }, "secure_key_source_menu_routing": { @@ -60,7 +60,7 @@ "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "Configure IP secure backbone key manually" + "secure_routing_manual": "Configure IP Secure backbone key manually" } }, "secure_knxkeys": { @@ -86,7 +86,7 @@ }, "secure_tunnel_manual": { "title": "Secure tunnelling", - "description": "Please enter your IP secure information.", + "description": "Please enter your IP Secure information.", "data": { "user_id": "User ID", "user_password": "User password", @@ -161,7 +161,7 @@ "telegram_log_size": "Telegram history limit" }, "data_description": { - "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", + "state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option.", "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } @@ -443,7 +443,7 @@ }, "entity_id": { "name": "Entity", - "description": "Entity id whose state or attribute shall be exposed." + "description": "Entity ID whose state or attribute shall be exposed." }, "attribute": { "name": "Entity attribute", diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index f87b94b23fdead..0bd51f27ab67e1 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -8,7 +8,6 @@ from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_WS_PORT, @@ -103,7 +103,7 @@ def __init__(self) -> None: self._discovery_name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 5b472e0c193f8d..8d5e76df71ee7f 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -41,7 +41,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_uuid": "Kodi instance does not have a unique id. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." + "no_uuid": "Kodi instance does not have a unique ID. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." } }, "device_automation": { @@ -57,15 +57,15 @@ "fields": { "media_type": { "name": "Media type", - "description": "Media type identifier. It must be one of SONG or ALBUM." + "description": "Media type identifier. It must be one of 'SONG' or 'ALBUM'." }, "media_id": { "name": "Media ID", - "description": "Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library." + "description": "Unique ID of the media entry to add (`songid` or albumid`). If not defined, Media name and Artist name are needed to search the Kodi music library." }, "media_name": { "name": "Media name", - "description": "Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist." + "description": "Optional media name for filtering media. Can be 'ALL' when Media type is 'ALBUM' and Artist name is specified, to add all songs from one artist." }, "artist_name": { "name": "Artist name", diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 65dd7cf39b3d41..7f5f4d8abd43fa 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -12,7 +12,6 @@ import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, BinarySensorDeviceClass, @@ -40,6 +39,11 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONF_ACTIVATION, @@ -254,7 +258,7 @@ async def async_step_import_confirm( return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered konnected panel. @@ -264,16 +268,16 @@ async def async_step_ssdp( _LOGGER.debug(discovery_info) try: - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: return self.async_abort(reason="not_konn_panel") if not any( - name in discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME] for name in KONN_PANEL_MODEL_NAMES ): _LOGGER.warning( "Discovered unrecognized Konnected device %s", - discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "Unknown"), + discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"), ) return self.async_abort(reason="not_konn_panel") diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index fa6aa92856b7e8..5f4393146f0372 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -101,8 +101,8 @@ async def async_setup(self) -> bool: model=f"{prod1} {prod2}", name=settings["scb:network"][hostname_id], sw_version=( - f'IOC: {device_local["Properties:VersionIOC"]}' - f' MC: {device_local["Properties:VersionMC"]}' + f"IOC: {device_local['Properties:VersionIOC']}" + f" MC: {device_local['Properties:VersionMC']}" ), ) diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index ecf30f9a197016..75a5c73703485f 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -40,7 +40,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca raise InvalidAuth from error if not locations: - raise NoLocations(f'No locations found for account {data["username"]}') + raise NoLocations(f"No locations found for account {data['username']}") return locations diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 70fb2c08b34001..87a9824423a736 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -17,7 +17,6 @@ BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -47,6 +46,7 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 05c5dea77d1ae9..23b0062fc82e86 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -23,12 +23,6 @@ import voluptuous as vol from yarl import URL -from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.data_entry_flow import AbortFlow @@ -44,6 +38,12 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_link_local from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 2a95285171212b..eb331650870867 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -30,4 +30,5 @@ def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: model_id=coordinator.data.model, name=coordinator.data.name, sw_version=coordinator.data.os_version, + serial_number=coordinator.data.serial_number, ) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index f66ffb0c6aeeaf..4c4359d0ddbe33 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.1.1"], + "requirements": ["demetriek==1.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index a1d922c2d80d8b..ccfd48a3abfd9a 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from demetriek import Device, LaMetricDevice +from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + range_fn: Callable[[Device], Range | None] has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -33,11 +34,9 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.display.brightness_limit, native_unit_of_measurement=PERCENTAGE, value_fn=lambda device: device.display.brightness, set_value_fn=lambda device, bri: device.display(brightness=int(bri)), @@ -45,11 +44,10 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.audio.volume_range if device.audio else None, + native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), @@ -93,6 +91,20 @@ def native_value(self) -> int | None: """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @property + def native_min_value(self) -> int: + """Return the min range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_min + return 0 + + @property + def native_max_value(self) -> int: + """Return the max range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_max + return 100 + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0fd6f5a12dc1ab..3c2f05fa535b3d 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -66,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" @@ -130,7 +138,7 @@ "description": "The message to display." }, "icon": { - "name": "Icon", + "name": "Icon ID", "description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons." }, "sound": { diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 360b732c02ebd7..1dff15c4f221ae 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -32,6 +32,7 @@ CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, DOMAIN, ) from .entity import LcnEntity @@ -93,6 +94,9 @@ def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: self.regulator_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint) self.is_lockable = config[CONF_DOMAIN_DATA][CONF_LOCKABLE] + self.target_value_locked = config[CONF_DOMAIN_DATA].get( + CONF_TARGET_VALUE_LOCKED, -1 + ) self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP] self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP] @@ -171,7 +175,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self._is_on = True self.async_write_ha_state() elif hvac_mode == HVACMode.OFF: - if not await self.device_connection.lock_regulator(self.regulator_id, True): + if not await self.device_connection.lock_regulator( + self.regulator_id, True, self.target_value_locked + ): return self._is_on = False self._target_temperature = None diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index cee9da9be43f53..b443e05def7f0d 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -35,6 +35,7 @@ CONF_TRANSITION = "transition" CONF_MOTOR = "motor" CONF_LOCKABLE = "lockable" +CONF_TARGET_VALUE_LOCKED = "target_value_locked" CONF_VARIABLE = "variable" CONF_VALUE = "value" CONF_RELVARREF = "value_reference" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 348305c775e06e..b999c6f3770268 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -90,9 +90,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: if domain_name == "cover": return cast(str, domain_data["motor"]) if domain_name == "climate": - return f'{domain_data["source"]}.{domain_data["setpoint"]}' + return f"{domain_data['source']}.{domain_data['setpoint']}" if domain_name == "scene": - return f'{domain_data["register"]}.{domain_data["scene"]}' + return f"{domain_data['register']}.{domain_data['scene']}" raise ValueError("Unknown domain") diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index f5eb1654588e1c..2ac183dcc9749a 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.1", "lcn-frontend==0.2.2"] + "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] } diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index c9c91b9843dd3f..809701c680a674 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -24,6 +24,7 @@ CONF_REGISTER, CONF_REVERSE_TIME, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, KEYS, LED_PORTS, @@ -58,6 +59,7 @@ vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool), + vol.Optional(CONF_TARGET_VALUE_LOCKED, default=-1): vol.Coerce(float), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS): vol.In( UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ), diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 47696719b735bf..0bdd85a3678449 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -17,7 +17,7 @@ "config": { "step": { "user": { - "title": "Setup LCN host", + "title": "Set up LCN host", "description": "Set up new connection to LCN host.", "data": { "host": "[%key:common::config_flow::data::name%]", @@ -30,8 +30,14 @@ "acknowledge": "Request acknowledgement from modules" }, "data_description": { - "dim_mode": "The number of steps used for dimming outputs.", - "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)." + "host": "Name of the LCN integration entry.", + "ip_address": "IP address or hostname of the PCHK server.", + "port": "Port used by the PCHK server.", + "username": "Username for authorization on the PCHK server.", + "password": "Password for authorization on the PCHK server.", + "sk_num_tries": "If you have a segment coupler in your LCN installation, increase this number to at least 3, so all segment couplers are identified correctly.", + "dim_mode": "The number of steps used for dimming outputs of all LCN modules.", + "acknowledge": "Retry sendig commands if no expected response is received from modules (increases bus traffic)." } }, "reconfigure": { @@ -47,6 +53,11 @@ "acknowledge": "[%key:component::lcn::config::step::user::data::acknowledge%]" }, "data_description": { + "ip_address": "[%key:component::lcn::config::step::user::data_description::ip_address%]", + "port": "[%key:component::lcn::config::step::user::data_description::port%]", + "username": "[%key:component::lcn::config::step::user::data_description::username%]", + "password": "[%key:component::lcn::config::step::user::data_description::password%]", + "sk_num_tries": "[%key:component::lcn::config::step::user::data_description::sk_num_tries%]", "dim_mode": "[%key:component::lcn::config::step::user::data_description::dim_mode%]", "acknowledge": "[%key:component::lcn::config::step::user::data_description::acknowledge%]" } @@ -65,15 +76,15 @@ "issues": { "deprecated_regulatorlock_sensor": { "title": "Deprecated LCN regulator lock binary sensor", - "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN regulator lock binary sensor entity `{entity}` is being used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." }, "deprecated_keylock_sensor": { "title": "Deprecated LCN key lock binary sensor", - "description": "Your LCN key lock binary sensor entity `{entity}` is beeing used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN key lock binary sensor entity `{entity}` is being used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." }, "deprecated_address_parameter": { "title": "Deprecated 'address' parameter", - "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device_id' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device ID' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } }, "services": { @@ -83,7 +94,7 @@ "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", - "description": "The device_id of the LCN module or group." + "description": "The device ID of the LCN module or group." }, "address": { "name": "Address", diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index d3268dfbf91bc1..46df71d4235602 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -423,25 +423,22 @@ async def async_create_or_update_device_in_config_entry( device_connection.is_group, ) - device_configs = [*config_entry.data[CONF_DEVICES]] - data = {**config_entry.data, CONF_DEVICES: device_configs} - for device_config in data[CONF_DEVICES]: - if tuple(device_config[CONF_ADDRESS]) == address: - break # device already in config_entry - else: - # create new device_entry - device_config = { - CONF_ADDRESS: address, - CONF_NAME: "", - CONF_HARDWARE_SERIAL: -1, - CONF_SOFTWARE_SERIAL: -1, - CONF_HARDWARE_TYPE: -1, - } - data[CONF_DEVICES].append(device_config) - - # update device_entry - await async_update_device_config(device_connection, device_config) + device_config = { + CONF_ADDRESS: address, + CONF_NAME: "", + CONF_HARDWARE_SERIAL: -1, + CONF_SOFTWARE_SERIAL: -1, + CONF_HARDWARE_TYPE: -1, + } + + device_configs = [ + device + for device in config_entry.data[CONF_DEVICES] + if tuple(device[CONF_ADDRESS]) != address + ] + data = {**config_entry.data, CONF_DEVICES: [*device_configs, device_config]} + await async_update_device_config(device_connection, device_config) hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index d3e21eeae9017c..2e64a590eafd65 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/const.py b/homeassistant/components/led_ble/const.py index 64c28d1ada5119..bf4dadd441c549 100644 --- a/homeassistant/components/led_ble/const.py +++ b/homeassistant/components/led_ble/const.py @@ -5,7 +5,7 @@ DOMAIN = "led_ble" DEVICE_TIMEOUT = 30 -LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue"} +LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue", "LD-0003"} UNSUPPORTED_SUB_MODEL = "LEDnetWF" diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 4aaaebc0006bb9..24e986000bb20f 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -28,6 +28,9 @@ }, { "local_name": "MELK-*" + }, + { + "local_name": "LD-0003" } ], "codeowners": ["@bdraco"], @@ -35,5 +38,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.20.0", "led-ble==1.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.1"] } diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py index 7091856f4fdcfd..0641749a2b9bd1 100644 --- a/homeassistant/components/lektrico/config_flow.py +++ b/homeassistant/components/lektrico/config_flow.py @@ -7,7 +7,6 @@ from lektricowifi import Device, DeviceConnectionError import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_HW_VERSION, @@ -17,6 +16,7 @@ ) from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -116,7 +116,7 @@ async def _get_lektrico_device_settings_and_treat_unique_id(self) -> None: self._serial_number = str(settings["serial_number"]) self._device_type = settings["type"] self._board_revision = settings["board_revision"] - self._name = f"{settings["type"]}_{self._serial_number}" + self._name = f"{settings['type']}_{self._serial_number}" # Check if already configured # Set unique id diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py new file mode 100644 index 00000000000000..905887463d75bd --- /dev/null +++ b/homeassistant/components/letpot/__init__.py @@ -0,0 +1,94 @@ +"""The LetPot integration.""" + +from __future__ import annotations + +import asyncio + +from letpot.client import LetPotClient +from letpot.converters import CONVERTERS +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, +) +from .coordinator import LetPotDeviceCoordinator + +PLATFORMS: list[Platform] = [Platform.TIME] + +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Set up LetPot from a config entry.""" + + auth = AuthenticationInfo( + access_token=entry.data[CONF_ACCESS_TOKEN], + access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES], + refresh_token=entry.data[CONF_REFRESH_TOKEN], + refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES], + user_id=entry.data[CONF_USER_ID], + email=entry.data[CONF_EMAIL], + ) + websession = async_get_clientsession(hass) + client = LetPotClient(websession, auth) + + if not auth.is_valid: + try: + auth = await client.refresh_token() + hass.config_entries.async_update_entry( + entry, + data={ + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + }, + ) + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + + try: + devices = await client.get_devices() + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + except LetPotException as exc: + raise ConfigEntryNotReady from exc + + coordinators: list[LetPotDeviceCoordinator] = [ + LetPotDeviceCoordinator(hass, auth, device) + for device in devices + if any(converter.supports_type(device.device_type) for converter in CONVERTERS) + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + for coordinator in entry.runtime_data: + coordinator.device_client.disconnect() + return unload_ok diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py new file mode 100644 index 00000000000000..bc710cd6aef1a4 --- /dev/null +++ b/homeassistant/components/letpot/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for the LetPot integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from letpot.client import LetPotClient +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD), + ), + } +) + + +class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LetPot.""" + + VERSION = 1 + + async def _async_validate_credentials( + self, email: str, password: str + ) -> tuple[dict[str, str], dict[str, Any] | None]: + """Try logging in to the LetPot account and returns credential info.""" + websession = async_get_clientsession(self.hass) + client = LetPotClient(websession) + try: + auth = await client.login(email, password) + except LetPotConnectionException: + return {"base": "cannot_connect"}, None + except LetPotAuthenticationException: + return {"base": "invalid_auth"}, None + except Exception: + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"}, None + else: + return {}, { + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + errors, data_dict = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=data_dict[CONF_EMAIL], data=data_dict + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + 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.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors, data_dict = await self._async_validate_credentials( + reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + if reauth_entry.unique_id != data_dict[CONF_USER_ID]: + # Abort if the received account is different and already added + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + reauth_entry, + unique_id=self.unique_id, + data_updates=data_dict, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_SCHEMA, + description_placeholders={"email": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/letpot/const.py b/homeassistant/components/letpot/const.py new file mode 100644 index 00000000000000..af01bbfdffc67a --- /dev/null +++ b/homeassistant/components/letpot/const.py @@ -0,0 +1,10 @@ +"""Constants for the LetPot integration.""" + +DOMAIN = "letpot" + +CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" +CONF_REFRESH_TOKEN = "refresh_token" +CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" +CONF_USER_ID = "user_id" + +REQUEST_UPDATE_TIMEOUT = 10 diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py new file mode 100644 index 00000000000000..a2a35d566c68a9 --- /dev/null +++ b/homeassistant/components/letpot/coordinator.py @@ -0,0 +1,67 @@ +"""Coordinator for the LetPot integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from letpot.deviceclient import LetPotDeviceClient +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REQUEST_UPDATE_TIMEOUT + +if TYPE_CHECKING: + from . import LetPotConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): + """Class to handle data updates for a specific garden.""" + + config_entry: LetPotConfigEntry + + device: LetPotDevice + device_client: LetPotDeviceClient + + def __init__( + self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"LetPot {device.serial_number}", + ) + self._info = info + self.device = device + self.device_client = LetPotDeviceClient(info, device.serial_number) + + def _handle_status_update(self, status: LetPotDeviceStatus) -> None: + """Distribute status update to entities.""" + self.async_set_updated_data(data=status) + + async def _async_setup(self) -> None: + """Set up subscription for coordinator.""" + try: + await self.device_client.subscribe(self._handle_status_update) + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + + async def _async_update_data(self) -> LetPotDeviceStatus: + """Request an update from the device and wait for a status update or timeout.""" + try: + async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): + await self.device_client.get_current_status() + except LetPotException as exc: + raise UpdateFailed(exc) from exc + + # The subscription task will have updated coordinator.data, so return that data. + # If we don't return anything here, coordinator.data will be set to None. + return self.data diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py new file mode 100644 index 00000000000000..c9a8953b5d5b41 --- /dev/null +++ b/homeassistant/components/letpot/entity.py @@ -0,0 +1,25 @@ +"""Base class for LetPot entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LetPotDeviceCoordinator + + +class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): + """Defines a base LetPot entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: + """Initialize a LetPot entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device.serial_number)}, + name=coordinator.device.name, + manufacturer="LetPot", + model=coordinator.device_client.device_model_name, + model_id=coordinator.device_client.device_model_code, + serial_number=coordinator.device.serial_number, + ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json new file mode 100644 index 00000000000000..f575279fa69adc --- /dev/null +++ b/homeassistant/components/letpot/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "letpot", + "name": "LetPot", + "codeowners": ["@jpelgrom"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/letpot", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["letpot==0.2.0"] +} diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml new file mode 100644 index 00000000000000..74b948ffbf741d --- /dev/null +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration only receives push-based updates. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-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: todo + config-entry-unloading: + status: done + comment: | + Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry. + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have configuration options. + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + 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: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json new file mode 100644 index 00000000000000..93913c2bc4d8a5 --- /dev/null +++ b/homeassistant/components/letpot/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your LetPot account.", + "password": "The password of your LetPot account." + } + }, + "reauth_confirm": { + "description": "The LetPot integration needs to re-authenticate your account {email}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::letpot::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "time": { + "light_schedule_end": { + "name": "Light off" + }, + "light_schedule_start": { + "name": "Light on" + } + } + } +} diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py new file mode 100644 index 00000000000000..229f02e0806f3b --- /dev/null +++ b/homeassistant/components/letpot/time.py @@ -0,0 +1,93 @@ +"""Support for LetPot time entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import LetPotDeviceStatus + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LetPotConfigEntry +from .coordinator import LetPotDeviceCoordinator +from .entity import LetPotEntity + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LetPotTimeEntityDescription(TimeEntityDescription): + """Describes a LetPot time entity.""" + + value_fn: Callable[[LetPotDeviceStatus], time | None] + set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + + +TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( + LetPotTimeEntityDescription( + key="light_schedule_end", + translation_key="light_schedule_end", + value_fn=lambda status: None if status is None else status.light_schedule_end, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=None, end=value + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotTimeEntityDescription( + key="light_schedule_start", + translation_key="light_schedule_start", + value_fn=lambda status: None if status is None else status.light_schedule_start, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=value, end=None + ), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LetPot time entities based on a config entry.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotTimeEntity(coordinator, description) + for description in TIME_SENSORS + for coordinator in coordinators + ) + + +class LetPotTimeEntity(LetPotEntity, TimeEntity): + """Defines a LetPot time entity.""" + + entity_description: LetPotTimeEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotTimeEntityDescription, + ) -> None: + """Initialize LetPot time entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_value(self) -> time | None: + """Return the time.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_value(self, value: time) -> None: + """Set the time.""" + await self.entity_description.set_value_fn( + self.coordinator.device_client, value + ) diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 8759869aad30b8..025f80f78b1e20 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -168,7 +168,7 @@ def on_message_received( async def async_handle_device_event(self, message: dict) -> None: """Handle received mqtt message.""" unique_id = ( - f"{message["deviceId"]}_{list(message["report"].keys())[0]}" + f"{message['deviceId']}_{list(message['report'].keys())[0]}" if message["deviceType"] == DeviceType.WASHTOWER else message["deviceId"] ) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 99b4df8176e29c..7baaab5240396e 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, time, timedelta import logging from thinqconnect import DeviceType @@ -22,6 +23,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -93,6 +95,11 @@ native_unit_of_measurement=UnitOfTime.HOURS, translation_key=ThinQProperty.FILTER_LIFETIME, ), + ThinQProperty.FILTER_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.FILTER_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.FILTER_LIFETIME, + ), } HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription( @@ -255,9 +262,90 @@ translation_key=ThinQProperty.WATER_TYPE, ), } +ELAPSED_DAY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_STATE, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_STATE, + ), + ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, + ), +} +TIME_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.LIGHT_START: SensorEntityDescription( + key=TimerProperty.LIGHT_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.LIGHT_START, + ), + TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_START, + ), + TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_STOP, + ), +} +TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.TOTAL: SensorEntityDescription( + key=TimerProperty.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.TOTAL, + ), + TimerProperty.RELATIVE_TO_START: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_START, + ), + TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_STOP, + ), + TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + ), + TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_START_WM, + ), + TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_STOP_WM, + ), + TimerProperty.REMAIN: SensorEntityDescription( + key=TimerProperty.REMAIN, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.REMAIN, + ), + TimerProperty.RUNNING: SensorEntityDescription( + key=TimerProperty.RUNNING, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RUNNING, + ), +} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -268,6 +356,12 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -278,6 +372,9 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -287,8 +384,11 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], @@ -303,6 +403,9 @@ PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -313,6 +416,8 @@ RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -322,6 +427,9 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -344,6 +452,7 @@ TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], + TIME_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -352,6 +461,8 @@ DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], + TIMER_SENSOR_DESC[TimerProperty.RUNNING], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], @@ -426,11 +537,59 @@ def __init__( if entity_description.device_class == SensorDeviceClass.ENUM: self._attr_options = self.data.options + self._device_state: str | None = None + self._device_state_id = ( + ThinQProperty.CURRENT_STATE + if self.location is None + else f"{self.location}_{ThinQProperty.CURRENT_STATE}" + ) + def _update_status(self) -> None: """Update status itself.""" super()._update_status() - self._attr_native_value = self.data.value + value = self.data.value + + if isinstance(value, time): + local_now = datetime.now( + tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) + ) + if value in [0, None, time.min]: + # Reset to None + value = None + elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + if self.entity_description.key in TIME_SENSOR_DESC: + # Set timestamp for time + value = local_now.replace(hour=value.hour, minute=value.minute) + else: + # Set timestamp for delta + new_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if ( + self.native_value is not None + and self._device_state == new_state + ): + # Skip update when same state + return + + self._device_state = new_state + time_delta = timedelta( + hours=value.hour, minutes=value.minute, seconds=value.second + ) + value = ( + (local_now - time_delta) + if self.entity_description.key == TimerProperty.RUNNING + else (local_now + time_delta) + ) + elif self.entity_description.device_class == SensorDeviceClass.DURATION: + # Set duration + value = self._get_duration( + value, self.entity_description.native_unit_of_measurement + ) + self._attr_native_value = value if (data_unit := self._get_unit_of_measurement(self.data.unit)) is not None: # For different from description's unit @@ -445,3 +604,10 @@ def _update_status(self) -> None: self.options, self.native_unit_of_measurement, ) + + def _get_duration(self, data: time, unit: str | None) -> float | None: + if unit == UnitOfTime.MINUTES: + return (data.hour * 60) + data.minute + if unit == UnitOfTime.SECONDS: + return (data.hour * 3600) + (data.minute * 60) + data.second + return 0 diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index b02361e65ca12c..805fcce53adce2 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -160,10 +160,8 @@ def native_value(self) -> str | int: def queue_str(item: LidarrQueueItem) -> str: """Return string description of queue item.""" - if ( - item.sizeleft > 0 - and item.timeleft == "00:00:00" - or not hasattr(item, "trackedDownloadState") + if (item.sizeleft > 0 and item.timeleft == "00:00:00") or not hasattr( + item, "trackedDownloadState" ): return "stopped" return item.trackedDownloadState diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 053bb72c4fdd37..ee55a7589e2f80 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -9,12 +9,12 @@ from aiolifx.connection import LIFXConnection import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( @@ -72,7 +72,7 @@ async def async_step_dhcp( return await self._async_handle_discovery(host) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" return await self._async_handle_discovery(host=discovery_info.host) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 41fa04057f7cd0..5558828a143c6c 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -83,7 +83,7 @@ class SkyType(IntEnum): CLOUDS = 2 -class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 +class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" def __init__( @@ -456,7 +456,7 @@ async def async_set_multizone_effect( ) self.active_effect = FirmwareEffect[effect.upper()] - async def async_set_matrix_effect( # noqa: PLR0917 + async def async_set_matrix_effect( self, effect: str, palette: list[tuple[int, int, int, int]] | None = None, diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json index 58a7c89e266264..c37d7641717728 100644 --- a/homeassistant/components/lifx/icons.json +++ b/homeassistant/components/lifx/icons.json @@ -26,6 +26,9 @@ }, "effect_stop": { "service": "mdi:stop" + }, + "paint_theme": { + "service": "mdi:palette" } } } diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 27e62717e96a08..16c39c252198c2 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,6 +8,7 @@ from typing import Any import aiolifx_effects +from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol @@ -42,6 +43,7 @@ SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_SKY = "effect_sky" SERVICE_EFFECT_STOP = "effect_stop" +SERVICE_PAINT_THEME = "paint_theme" ATTR_CHANGE = "change" ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" @@ -83,6 +85,8 @@ EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] +PAINT_THEME_DEFAULT_TRANSITION = 1 + PULSE_MODE_BLINK = "blink" PULSE_MODE_BREATHE = "breathe" PULSE_MODE_PING = "ping" @@ -201,17 +205,30 @@ } ) - -SERVICES = ( - SERVICE_EFFECT_COLORLOOP, - SERVICE_EFFECT_FLAME, - SERVICE_EFFECT_MORPH, - SERVICE_EFFECT_MOVE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_SKY, - SERVICE_EFFECT_STOP, +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)), + vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional( + vol.In(ThemeLibrary().themes) + ), + vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } ) +SERVICES_SCHEMA = { + SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, + SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, + SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, + SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, + SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, + SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, + SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, + SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, +} + class LIFXManager: """Representation of all known LIFX entities.""" @@ -225,7 +242,7 @@ def __init__(self, hass: HomeAssistant) -> None: @callback def async_unload(self) -> None: """Release resources.""" - for service in SERVICES: + for service in SERVICES_SCHEMA: self.hass.services.async_remove(DOMAIN, service) @callback @@ -253,55 +270,219 @@ async def service_handler(service: ServiceCall) -> None: if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_PULSE, - service_handler, - schema=LIFX_EFFECT_PULSE_SCHEMA, + for service, schema in SERVICES_SCHEMA.items(): + self.hass.services.async_register( + DOMAIN, service, service_handler, schema=schema + ) + + @staticmethod + def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: + """Either return the predefined theme or build one from the palette.""" + if palette is None: + return ThemeLibrary().get_theme(theme_name) + + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + return theme + + async def _start_effect_flame( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Flame effect.""" + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_FLAME, + speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + async def _start_paint_theme( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Paint a theme across one or more LIFX bulbs.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await ThemePainter(self.hass.loop).paint( + theme, + bulbs, + duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION), + power_on=kwargs.get(ATTR_POWER_ON, True), ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_COLORLOOP, - service_handler, - schema=LIFX_EFFECT_COLORLOOP_SCHEMA, + async def _start_effect_morph( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Morph effect.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_MORPH, + speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_FLAME, - service_handler, - schema=LIFX_EFFECT_FLAME_SCHEMA, + async def _start_effect_move( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Move effect.""" + await asyncio.gather( + *( + coordinator.async_set_multizone_effect( + effect=EFFECT_MOVE, + speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), + direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION), + theme_name=kwargs.get(ATTR_THEME), + power_on=kwargs.get(ATTR_POWER_ON, False), + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MORPH, - service_handler, - schema=LIFX_EFFECT_MORPH_SCHEMA, + async def _start_effect_pulse( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software-based Pulse effect.""" + effect = aiolifx_effects.EffectPulse( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + cycles=kwargs.get(ATTR_CYCLES), + mode=kwargs.get(ATTR_MODE), + hsbk=find_hsbk(self.hass, **kwargs), ) + await self.effects_conductor.start(effect, bulbs) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MOVE, - service_handler, - schema=LIFX_EFFECT_MOVE_SCHEMA, + async def _start_effect_colorloop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software based Color Loop effect.""" + brightness = None + saturation_max = None + saturation_min = None + + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + elif ATTR_BRIGHTNESS_PCT in kwargs: + brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) + + if ATTR_SATURATION_MAX in kwargs: + saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) + + if ATTR_SATURATION_MIN in kwargs: + saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) + + effect = aiolifx_effects.EffectColorloop( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + change=kwargs.get(ATTR_CHANGE), + spread=kwargs.get(ATTR_SPREAD), + transition=kwargs.get(ATTR_TRANSITION), + brightness=brightness, + saturation_max=saturation_max, + saturation_min=saturation_min, ) + await self.effects_conductor.start(effect, bulbs) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_SKY, - service_handler, - schema=LIFX_EFFECT_SKY_SCHEMA, + async def _start_effect_sky( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Sky effect.""" + palette = kwargs.get(ATTR_PALETTE) + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + + speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) + sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) + + cloud_saturation_min = kwargs.get( + ATTR_CLOUD_SATURATION_MIN, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, + ) + cloud_saturation_max = kwargs.get( + ATTR_CLOUD_SATURATION_MAX, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_STOP, - service_handler, - schema=LIFX_EFFECT_STOP_SCHEMA, + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_SKY, + speed=speed, + sky_type=sky_type, + cloud_saturation_min=cloud_saturation_min, + cloud_saturation_max=cloud_saturation_max, + palette=theme.colors, + ) + for coordinator in coordinators + ) ) + async def _start_effect_stop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Stop any running software or firmware effect.""" + await self.effects_conductor.stop(bulbs) + + for coordinator in coordinators: + await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False) + await coordinator.async_set_multizone_effect( + effect=EFFECT_OFF, power_on=False + ) + + _effect_dispatch = { + SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop, + SERVICE_EFFECT_FLAME: _start_effect_flame, + SERVICE_EFFECT_MORPH: _start_effect_morph, + SERVICE_EFFECT_MOVE: _start_effect_move, + SERVICE_EFFECT_PULSE: _start_effect_pulse, + SERVICE_EFFECT_SKY: _start_effect_sky, + SERVICE_EFFECT_STOP: _start_effect_stop, + SERVICE_PAINT_THEME: _start_paint_theme, + } + async def start_effect( self, entity_ids: set[str], service: str, **kwargs: Any ) -> None: @@ -318,137 +499,5 @@ async def start_effect( coordinators.append(coordinator) bulbs.append(coordinator.device) - if service == SERVICE_EFFECT_FLAME: - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_FLAME, - speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MORPH: - theme_name = kwargs.get(ATTR_THEME, "exciting") - palette = kwargs.get(ATTR_PALETTE) - - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - else: - theme = ThemeLibrary().get_theme(theme_name) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_MORPH, - speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), - palette=theme.colors, - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MOVE: - await asyncio.gather( - *( - coordinator.async_set_multizone_effect( - effect=EFFECT_MOVE, - speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), - direction=kwargs.get( - ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION - ), - theme_name=kwargs.get(ATTR_THEME), - power_on=kwargs.get(ATTR_POWER_ON, False), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_PULSE: - effect = aiolifx_effects.EffectPulse( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - cycles=kwargs.get(ATTR_CYCLES), - mode=kwargs.get(ATTR_MODE), - hsbk=find_hsbk(self.hass, **kwargs), - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_COLORLOOP: - brightness = None - saturation_max = None - saturation_min = None - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - elif ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16( - round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100) - ) - - if ATTR_SATURATION_MAX in kwargs: - saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) - - if ATTR_SATURATION_MIN in kwargs: - saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) - - effect = aiolifx_effects.EffectColorloop( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - change=kwargs.get(ATTR_CHANGE), - spread=kwargs.get(ATTR_SPREAD), - transition=kwargs.get(ATTR_TRANSITION), - brightness=brightness, - saturation_max=saturation_max, - saturation_min=saturation_min, - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_SKY: - palette = kwargs.get(ATTR_PALETTE) - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - - speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) - sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) - - cloud_saturation_min = kwargs.get( - ATTR_CLOUD_SATURATION_MIN, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, - ) - cloud_saturation_max = kwargs.get( - ATTR_CLOUD_SATURATION_MAX, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, - ) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_SKY, - speed=speed, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - palette=theme.colors, - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_STOP: - await self.effects_conductor.stop(bulbs) - - for coordinator in coordinators: - await coordinator.async_set_matrix_effect( - effect=EFFECT_OFF, power_on=False - ) - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, power_on=False - ) + if start_effect_func := self._effect_dispatch.get(service): + await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 2e16eb2082b672..8d460c2532283f 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -53,6 +53,6 @@ "requirements": [ "aiolifx==1.1.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.5.5" + "aiolifx-themes==0.6.4" ] } diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index c2eb2e249cbb5f..ac4fbfc15af2cd 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -186,28 +186,46 @@ effect_move: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -255,28 +273,46 @@ effect_morph: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -338,3 +374,73 @@ effect_stop: entity: integration: lifx domain: light +paint_theme: + target: + entity: + integration: lifx + domain: light + fields: + palette: + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + example: exciting + default: exciting + selector: + select: + mode: dropdown + options: + - "autumn" + - "blissful" + - "bias_lighting" + - "calaveras" + - "cheerful" + - "christmas" + - "dream" + - "energizing" + - "epic" + - "evening" + - "exciting" + - "fantasy" + - "focusing" + - "gentle" + - "halloween" + - "hanukkah" + - "holly" + - "hygge" + - "independence" + - "intense" + - "love" + - "kwanzaa" + - "mellow" + - "party" + - "peaceful" + - "powerful" + - "proud" + - "pumpkin" + - "relaxing" + - "romance" + - "santa" + - "serene" + - "shamrock" + - "soothing" + - "spacey" + - "sports" + - "spring" + - "stardust" + - "thanksgiving" + - "tranquil" + - "warming" + - "zombie" + transition: + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + power_on: + default: true + selector: + boolean: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 19d86e57f09876..39102d904d5f1f 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -209,7 +209,7 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", @@ -254,6 +254,28 @@ "effect_stop": { "name": "Stop effect", "description": "Stops a running effect." + }, + "paint_theme": { + "name": "Paint Theme", + "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "fields": { + "palette": { + "name": "Palette", + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "Predefined color theme to paint. Overridden by the palette attribute." + }, + "transition": { + "name": "Transition", + "description": "Duration in seconds to paint the theme." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights will be turned on before painting the theme." + } + } } } } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 33bd259469b915..412ee1e6c1609b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -354,7 +354,7 @@ def filter_turn_off_params( if not params: return params - supported_features = light.supported_features + supported_features = light.supported_features_compat if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) @@ -366,7 +366,7 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) @@ -1093,7 +1093,7 @@ def effect(self) -> str | None: def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -1255,11 +1255,12 @@ def __validate_supported_color_modes( def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self.supported_color_modes legacy_supported_color_modes = ( supported_color_modes or self._light_internal_supported_color_modes ) + supported_features_value = supported_features.value _is_on = self.is_on color_mode = self._light_internal_color_mode if _is_on else None @@ -1278,6 +1279,13 @@ def state_attributes(self) -> dict[str, Any] | None: data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None + elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value: + # Backwards compatibility for ambiguous / incomplete states + # Warning is printed by supported_features_compat, remove in 2025.1 + if _is_on: + data[ATTR_BRIGHTNESS] = self.brightness + else: + data[ATTR_BRIGHTNESS] = None if color_temp_supported(supported_color_modes): if color_mode == ColorMode.COLOR_TEMP: @@ -1292,6 +1300,21 @@ def state_attributes(self) -> dict[str, Any] | None: else: data[ATTR_COLOR_TEMP_KELVIN] = None data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None + elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: + # Backwards compatibility + # Warning is printed by supported_features_compat, remove in 2025.1 + if _is_on: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) + ) + else: + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None + else: + data[ATTR_COLOR_TEMP_KELVIN] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None if color_supported(legacy_supported_color_modes) or color_temp_supported( legacy_supported_color_modes @@ -1329,7 +1352,24 @@ def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: type(self), report_issue, ) - return {ColorMode.ONOFF} + supported_features = self.supported_features_compat + supported_features_value = supported_features.value + supported_color_modes: set[ColorMode] = set() + + if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: + supported_color_modes.add(ColorMode.COLOR_TEMP) + if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value: + supported_color_modes.add(ColorMode.HS) + if ( + not supported_color_modes + and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value + ): + supported_color_modes = {ColorMode.BRIGHTNESS} + + if not supported_color_modes: + supported_color_modes = {ColorMode.ONOFF} + + return supported_color_modes @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: @@ -1341,6 +1381,37 @@ def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features + def __should_report_light_issue(self) -> bool: """Return if light color mode issues should be reported.""" if not self.platform: diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 7dfdce238ff20f..11e4aabf2572ff 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -9,9 +9,9 @@ from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import async_get_client_session @@ -27,7 +27,7 @@ def __init__(self) -> None: self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 88396f9f9c17e7..4f1deb9a567df5 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.5.0"] + "requirements": ["pylitterbot==2024.0.0"] } diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 77aa71740f11c1..a53a604daae169 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -115,9 +115,9 @@ def __init__( include_entity_name: bool = True, ) -> None: """Init the event stream.""" - assert not ( - context_id and (entity_ids or device_ids) - ), "can't pass in both context_id and (entity_ids or device_ids)" + assert not (context_id and (entity_ids or device_ids)), ( + "can't pass in both context_id and (entity_ids or device_ids)" + ) self.hass = hass self.ent_reg = er.async_get(hass) self.event_types = event_types diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index aaf98a06fa85c3..abf2982765d277 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -9,10 +9,10 @@ from aiolookin import Device, LookInHttpProtocol, NoUsableService import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +28,7 @@ def __init__(self) -> None: self._name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Start a discovery flow from zeroconf.""" uid: str = discovery_info.hostname.removesuffix(".local.") diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 8c82a7a69647f3..a3879d0412f770 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -11,12 +11,12 @@ import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index e4cd4b71045d4f..38eae7eb7b2570 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -3,7 +3,7 @@ "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Login at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", + "description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token.", "data": { "name": "Name of your lock in the LOQED app.", "api_token": "[%key:common::config_flow::data::api_token%]" diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 82a92b94ae5d8d..c380a296fc018a 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -101,7 +101,7 @@ async def async_browse_media( BrowseMedia( title=view["title"], media_class=MediaClass.APP, - media_content_id=f'{info["url_path"]}/{view["path"]}', + media_content_id=f"{info['url_path']}/{view['path']}", media_content_type=DOMAIN, thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", can_play=True, diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index a74de46346bcc5..e56758b0af6ad1 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -53,7 +53,7 @@ async def async_setup_entry( # Append the child device name to the end of the parent keypad # name to create the entity name - full_name = f'{parent_device_info.get("name")} {device_name}' + full_name = f"{parent_device_info.get('name')} {device_name}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device entities.append( diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index cd566b767fb0e4..767c3d2f2b7850 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -12,10 +12,10 @@ from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, @@ -69,7 +69,7 @@ async def async_step_user( return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" hostname = discovery_info.hostname @@ -90,7 +90,7 @@ async def async_step_zeroconf( return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 7493878beceaf9..809b9e8d007eb8 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -16,6 +16,7 @@ DEVICE_TYPE_WHITE_TUNE = "WhiteTune" DEVICE_TYPE_SPECTRUM_TUNE = "SpectrumTune" +DEVICE_TYPE_COLOR_TUNE = "ColorTune" MANUFACTURER = "Lutron Electronics Co., Inc" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 11da2220be9874..d8fac38ce2bfb1 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -99,6 +99,7 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: PYLUTRON_TYPE_TO_CLASSES = { "SerenaTiltOnlyWoodBlind": LutronCasetaTiltOnlyBlind, + "Tilt": LutronCasetaTiltOnlyBlind, "SerenaHoneycombShade": LutronCasetaShade, "SerenaRollerShade": LutronCasetaShade, "TriathlonHoneycombShade": LutronCasetaShade, diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 0b432f880458a4..79b792935a8670 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,6 +277,20 @@ def _reverse_dict(forward_dict: dict) -> dict: } ) +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { + "button_0": 2, + "button_2": 4, +} +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { + "button_0": 0, + "button_2": 2, +} +PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), + } +) + DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -288,6 +302,7 @@ def _reverse_dict(forward_dict: dict) -> dict: "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -300,6 +315,7 @@ def _reverse_dict(forward_dict: dict) -> dict: "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -312,6 +328,7 @@ def _reverse_dict(forward_dict: dict) -> dict: "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -326,6 +343,7 @@ def _reverse_dict(forward_dict: dict) -> dict: PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index 146ed826c1455b..722c9a15d9130a 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -24,7 +24,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_TYPE_SPECTRUM_TUNE, DEVICE_TYPE_WHITE_TUNE +from .const import ( + DEVICE_TYPE_COLOR_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_WHITE_TUNE, +) from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaData @@ -35,9 +39,18 @@ ColorMode.WHITE, }, DEVICE_TYPE_WHITE_TUNE: {ColorMode.COLOR_TEMP}, + DEVICE_TYPE_COLOR_TUNE: { + ColorMode.HS, + ColorMode.COLOR_TEMP, + ColorMode.WHITE, + }, } -WARM_DEVICE_TYPES = {DEVICE_TYPE_WHITE_TUNE, DEVICE_TYPE_SPECTRUM_TUNE} +WARM_DEVICE_TYPES = { + DEVICE_TYPE_WHITE_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, +} def to_lutron_level(level): @@ -90,8 +103,14 @@ def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None: ) self.supports_warm_cool = light_type in WARM_DEVICE_TYPES - self.supports_warm_dim = light_type == DEVICE_TYPE_SPECTRUM_TUNE - self.supports_spectrum_tune = light_type == DEVICE_TYPE_SPECTRUM_TUNE + self.supports_warm_dim = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) + self.supports_spectrum_tune = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: """Return minimum supported color temperature. diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index ec278615743316..bbb6df41a89bb6 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.22.0"], + "requirements": ["pylutron-caseta==0.23.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 5037d077a02a6b..66f23926fbf28e 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -44,7 +44,7 @@ def __init__(self, device, data): parent_keypad = keypads[device["parent_device"]] parent_device_info = parent_keypad["device_info"] # Append the child device name to the end of the parent keypad name to create the entity name - self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' + self._attr_name = f"{parent_device_info['name']} {device['device_name']}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device self._attr_device_info = parent_device_info diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 1a4f0f79aae427..19f23afddaf593 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup madVR Envy", + "title": "Set up madVR Envy", "description": "Your device needs to be on in order to add the integation.", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -21,8 +21,8 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of your madVR Envy device.", - "port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default." + "host": "[%key:component::madvr::config::step::user::data_description::host%]", + "port": "[%key:component::madvr::config::step::user::data_description::port%]" } } }, @@ -33,7 +33,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable." + "no_mac": "A MAC address was not found. It is required to identify the device. Please ensure your device is connectable." } }, "entity": { diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index bdfdbbf6e99e74..dd76d44a02c55e 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -115,7 +115,7 @@ def _upload_media(self, media_path: Any = None) -> Any: try: mediadata = self.client.media_post(media_path, mime_type=media_type) except MastodonAPIError: - LOGGER.error(f"Unable to upload image {media_path}") + LOGGER.error("Unable to upload image %s", media_path) return mediadata diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index e751387d7e8cc6..e3e30fb704b8e9 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -178,7 +178,7 @@ async def _client_listen( if entry.state != ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) if entry.state != ConfigEntryState.LOADED: diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 0378d0ea226d99..be6f024695d7a3 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -310,13 +310,11 @@ def _update_from_device(self) -> None: ): match running_state_value: case ( - ThermostatRunningState.Heat - | ThermostatRunningState.HeatStage2 + ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2 ): self._attr_hvac_action = HVACAction.HEATING case ( - ThermostatRunningState.Cool - | ThermostatRunningState.CoolStage2 + ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2 ): self._attr_hvac_action = HVACAction.COOLING case ( diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 6f7505eb61ff43..0c73ccd40897e5 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -16,7 +16,6 @@ AddonState, ) from homeassistant.components.onboarding import async_is_onboarded -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -25,6 +24,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .addon import get_addon_manager from .const import ( diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index adcdcd051376d8..ef29601b831e2a 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,9 @@ }, "valve_position": { "default": "mdi:valve" + }, + "battery_replacement_description": { + "default": "mdi:battery-sync-outline" } } } diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index da706d4a73b101..433d978cef74aa 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -1,4 +1,4 @@ -"""Model Context Protocol transport portocol for Server Sent Events (SSE). +"""Model Context Protocol transport protocol for Server Sent Events (SSE). This registers HTTP endpoints that supports SSE as a transport layer for the Model Context Protocol. There are two HTTP endpoints: @@ -62,9 +62,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: if config_entry.state == ConfigEntryState.LOADED ] if not config_entries: - raise HTTPNotFound(body="Model Context Protocol server is not configured") + raise HTTPNotFound(text="Model Context Protocol server is not configured") if len(config_entries) > 1: - raise HTTPNotFound(body="Found multiple Model Context Protocol configurations") + raise HTTPNotFound(text="Found multiple Model Context Protocol configurations") return config_entries[0] @@ -147,7 +147,7 @@ async def post( """Process incoming messages for the Model Context Protocol. The request passes a session ID which is used to identify the original - SSE connection. This view parses incoming messagess from the transport + SSE connection. This view parses incoming messages from the transport layer then writes them to the MCP server stream for the session. """ hass = request.app[KEY_HASS] @@ -156,14 +156,14 @@ async def post( session_manager = config_entry.runtime_data if (session := session_manager.get(session_id)) is None: _LOGGER.info("Could not find session ID: '%s'", session_id) - raise HTTPNotFound(body=f"Could not find session ID '{session_id}'") + raise HTTPNotFound(text=f"Could not find session ID '{session_id}'") json_data = await request.json() try: message = types.JSONRPCMessage.model_validate(json_data) except ValueError as err: _LOGGER.info("Failed to parse message: %s", err) - raise HTTPBadRequest(body="Could not parse message") from err + raise HTTPBadRequest(text="Could not parse message") from err _LOGGER.debug("Received client message: %s", message) await session.read_stream_writer.send(message) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 755d2c39065bd4..18b2e5bc417396 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.7.0"], + "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], "single_config_entry": true } diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 144904fe58c5dc..becca8e6da8d92 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.12.23"], + "requirements": ["yt-dlp[default]==2025.01.15"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e7bbe1d19bd29f..b82cab401c5b42 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -773,6 +773,19 @@ def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -912,85 +925,87 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat + ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1019,7 +1034,7 @@ async def async_volume_up(self) -> None: if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1037,7 +1052,7 @@ async def async_volume_down(self) -> None: if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1080,7 +1095,7 @@ def media_image_local(self) -> str | None: def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( source_list := self.source_list @@ -1286,7 +1301,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 1c9ba929b38043..be06ae22cdca8c 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -310,7 +310,7 @@ "fields": { "group_members": { "name": "Group members", - "description": "The players which will be synced with the playback specified in `target`." + "description": "The players which will be synced with the playback specified in 'Targets'." } } }, diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 3ea8f581245a24..5c6165a34776e2 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -38,18 +38,18 @@ __all__ = [ "DOMAIN", - "is_media_source_id", - "generate_media_source_id", - "async_browse_media", - "async_resolve_media", + "MEDIA_CLASS_MAP", + "MEDIA_MIME_TYPES", "BrowseMediaSource", - "PlayMedia", - "MediaSourceItem", - "Unresolvable", "MediaSource", "MediaSourceError", - "MEDIA_CLASS_MAP", - "MEDIA_MIME_TYPES", + "MediaSourceItem", + "PlayMedia", + "Unresolvable", + "async_browse_media", + "async_resolve_media", + "generate_media_source_id", + "is_media_source_id", ] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index b604ee5016e860..d2c9d67f29a1b2 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -126,9 +126,7 @@ async def async_reauthenticate_client( HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" @@ -165,9 +163,7 @@ async def async_step_reconfigure( HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 1d4f8293c5efe5..4b79b046b7592d 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -6,6 +6,7 @@ from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain +from requests import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -83,7 +84,13 @@ async def _async_update_data_alert() -> CurrentPhenomenons: update_method=_async_update_data_rain, update_interval=SCAN_INTERVAL_RAIN, ) - await coordinator_rain.async_config_entry_first_refresh() + try: + await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001 + except RequestException: + _LOGGER.warning( + "1 hour rain forecast not available: %s is not in covered zone", + entry.title, + ) department = coordinator_forecast.data.position.get("dept") _LOGGER.debug( @@ -128,8 +135,9 @@ async def _async_update_data_alert() -> CurrentPhenomenons: hass.data[DOMAIN][entry.entry_id] = { UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_RAIN: coordinator_rain, } + if coordinator_rain and coordinator_rain.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain if coordinator_alert and coordinator_alert.last_update_success: hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index d8dbdfc4265205..826716f1679d66 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -187,7 +187,7 @@ async def async_setup_entry( """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] - coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] + coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN) coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT ) diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml new file mode 100644 index 00000000000000..fc3db3b307596b --- /dev/null +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: + status: todo + comment: Check removal and replacement of name in config flow with the title (server address). + config-flow-test-coverage: + status: todo + comment: | + Merge test_show_config_form with full flow test. + Move full flow test to the top of all tests. + All test cases should end in either CREATE_ENTRY or ABORT. + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if either the initialization or + refresh of coordinator isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency mcstatus, + the server address is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: + status: todo + comment: | + Although this is handled by the coordinator and no service actions are provided, + PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + reauthentication-flow: + status: exempt + comment: No authentication is required for the integration. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A minecraft server can only have one device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: + status: done + comment: | + Lookup API of the dependency mcstatus for Bedrock Edition servers is not async, + but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ + https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59c28..03bcc98de40ecf 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -98,9 +98,9 @@ def __init__( def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: return None - assert isinstance( - val, (float, int) - ), f"Expected float or int but {config_name} was {type(val)}" + assert isinstance(val, (float, int)), ( + f"Expected float or int but {config_name} was {type(val)}" + ) return val self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index fc25a329c11e8b..120175c65c2374 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.7.4"] + "requirements": ["pymodbus==3.8.3"] } diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 98e6708a34c513..237fafa69d75dd 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS @@ -30,9 +31,7 @@ def __init__(self) -> None: """Set up flow instance.""" self._device: str | None = None - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index 3c217b5747f0bb..d10c7604722ab8 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -7,10 +7,10 @@ from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -39,7 +39,7 @@ async def async_step_user( return await self._handle_config_flow() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.hostname.rstrip(".") diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 5bfdc02c61e0b6..e6ab84a4e74e29 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -87,7 +87,7 @@ async def validate_login( except LoginFailedException as err: raise InvalidAuth from err - LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Connection successful - saving session to file %s", SESSION_FILE) LOGGER.debug("Obtaining subscription id") subs: MonarchSubscription = await monarch_client.get_subscription_details() assert subs is not None diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e961880375c39b..d8d1e7c21f1c62 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -7,7 +7,6 @@ from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_INTERFACE, @@ -82,7 +82,7 @@ def async_get_options_flow( return OptionsFlowHandler() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress).replace(":", "") diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 19d3557d36b48f..50a1e334f1d039 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -7,7 +7,6 @@ import motionmount import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +14,7 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, EMPTY_MAC @@ -80,7 +80,7 @@ async def async_step_user( return self.async_create_entry(title=name, data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 624f99d350ac42..8b16e9fa53d3f3 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -38,7 +38,7 @@ # Loading the config flow file will register the flow from . import debug_info, discovery -from .client import ( # noqa: F401 +from .client import ( MQTT, async_publish, async_subscribe, @@ -46,9 +46,9 @@ publish, subscribe, ) -from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE -from .const import ( # noqa: F401 +from .const import ( ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -69,6 +69,8 @@ CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, @@ -76,10 +78,11 @@ DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, + ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, ) -from .models import ( # noqa: F401 +from .models import ( DATA_MQTT, DATA_MQTT_AVAILABLE, MqttCommandTemplate, @@ -90,13 +93,13 @@ ReceiveMessage, convert_outgoing_mqtt_payload, ) -from .subscription import ( # noqa: F401 +from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, ) -from .util import ( # noqa: F401 +from .util import ( async_create_certificate_temp_files, async_forward_entry_setup_and_setup_discovery, async_wait_for_mqtt_client, @@ -107,6 +110,83 @@ valid_subscribe_topic, ) +__all__ = [ + "ATTR_PAYLOAD", + "ATTR_QOS", + "ATTR_RETAIN", + "ATTR_TOPIC", + "CONFIG_ENTRY_MINOR_VERSION", + "CONFIG_ENTRY_VERSION", + "CONF_BIRTH_MESSAGE", + "CONF_BROKER", + "CONF_CERTIFICATE", + "CONF_CLIENT_CERT", + "CONF_CLIENT_KEY", + "CONF_COMMAND_TOPIC", + "CONF_DISCOVERY_PREFIX", + "CONF_KEEPALIVE", + "CONF_QOS", + "CONF_STATE_TOPIC", + "CONF_TLS_INSECURE", + "CONF_TOPIC", + "CONF_TRANSPORT", + "CONF_WILL_MESSAGE", + "CONF_WS_HEADERS", + "CONF_WS_PATH", + "DATA_MQTT", + "DATA_MQTT_AVAILABLE", + "DEFAULT_DISCOVERY", + "DEFAULT_ENCODING", + "DEFAULT_PREFIX", + "DEFAULT_QOS", + "DEFAULT_RETAIN", + "DOMAIN", + "ENTITY_PLATFORMS", + "ENTRY_OPTION_FIELDS", + "MQTT", + "MQTT_BASE_SCHEMA", + "MQTT_CONNECTION_STATE", + "MQTT_RO_SCHEMA", + "MQTT_RW_SCHEMA", + "SERVICE_RELOAD", + "TEMPLATE_ERRORS", + "EntitySubscription", + "MqttCommandTemplate", + "MqttData", + "MqttValueTemplate", + "PayloadSentinel", + "PublishPayloadType", + "ReceiveMessage", + "SetupPhases", + "async_check_config_schema", + "async_create_certificate_temp_files", + "async_forward_entry_setup_and_setup_discovery", + "async_migrate_entry", + "async_prepare_subscribe_topics", + "async_publish", + "async_remove_config_entry_device", + "async_setup", + "async_setup_entry", + "async_subscribe", + "async_subscribe_connection_status", + "async_subscribe_topics", + "async_unload_entry", + "async_unsubscribe_topics", + "async_wait_for_mqtt_client", + "convert_outgoing_mqtt_payload", + "create_eager_task", + "is_connected", + "mqtt_config_entry_enabled", + "platforms_from_config", + "publish", + "subscribe", + "valid_publish_topic", + "valid_qos_schema", + "valid_subscribe_topic", + "websocket_mqtt_info", + "websocket_subscribe", +] + _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" @@ -282,15 +362,45 @@ async def finish_dump(_: datetime) -> None: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate the options from config entry data.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + data: dict[str, Any] = dict(entry.data) + options: dict[str, Any] = dict(entry.options) + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version < 2: + # Can be removed when config entry is bumped to version 2.1 + # with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 + # From 2026.1 we will write version 2.1 + for key in ENTRY_OPTION_FIELDS: + if key not in data: + continue + options[key] = data.pop(key) + hass.config_entries.async_update_entry( + entry, + data=data, + options=options, + version=CONFIG_ENTRY_VERSION, + minor_version=CONFIG_ENTRY_MINOR_VERSION, + ) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - conf: dict[str, Any] mqtt_data: MqttData async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration - conf = dict(entry.data) + conf = dict(entry.data | entry.options) hass_config = await conf_util.async_hass_config_yaml(hass) mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) await async_create_certificate_temp_files(hass, conf) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 65e24d5d78063d..584b238b3a87b0 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -23,6 +23,7 @@ "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", + "clr_temp_k": "color_temp_kelvin", "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", @@ -92,6 +93,8 @@ "min_hum": "min_humidity", "max_mirs": "max_mireds", "min_mirs": "min_mireds", + "max_k": "max_kelvin", + "min_k": "min_kelvin", "max_temp": "max_temp", "min_temp": "min_temp", "migr_discvry": "migrate_discovery", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 6500c9f91c9ba4..16a02e4956edca 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -220,8 +220,7 @@ def async_subscribe_internal( mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly", + f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly", translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 0081246c7058c4..a4d400dfea23d5 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -76,6 +76,8 @@ CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, @@ -205,7 +207,9 @@ def update_password_from_user_input( class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + # Can be bumped to version 2.1 with HA Core 2026.1.0 + VERSION = CONFIG_ENTRY_VERSION # 1 + MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2 _hassio_discovery: dict[str, Any] | None = None _addon_manager: AddonManager @@ -496,7 +500,6 @@ async def async_step_broker( reconfigure_entry, data=validated_user_input, ) - validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY return self.async_create_entry( title=validated_user_input[CONF_BROKER], data=validated_user_input, @@ -564,58 +567,17 @@ async def async_step_hassio_confirm( class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self) -> None: - """Initialize MQTT options flow.""" - self.broker_config: dict[str, Any] = {} - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" - return await self.async_step_broker() - - async def async_step_broker( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the MQTT broker configuration.""" - errors: dict[str, str] = {} - fields: OrderedDict[Any, Any] = OrderedDict() - validated_user_input: dict[str, Any] = {} - if await async_get_broker_settings( - self, - fields, - self.config_entry.data, - user_input, - validated_user_input, - errors, - ): - self.broker_config.update( - update_password_from_user_input( - self.config_entry.data.get(CONF_PASSWORD), validated_user_input - ), - ) - can_connect = await self.hass.async_add_executor_job( - try_connection, - self.broker_config, - ) - - if can_connect: - return await self.async_step_options() - - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="broker", - data_schema=vol.Schema(fields), - errors=errors, - last_step=False, - ) + return await self.async_step_options() async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the MQTT options.""" errors = {} - current_config = self.config_entry.data - options_config: dict[str, Any] = {} + + options_config: dict[str, Any] = dict(self.config_entry.options) bad_input: bool = False def _birth_will(birt_or_will: str) -> dict[str, Any]: @@ -674,26 +636,18 @@ def _validate( options_config[CONF_WILL_MESSAGE] = {} if not bad_input: - updated_config = {} - updated_config.update(self.broker_config) - updated_config.update(options_config) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=updated_config, - title=str(self.broker_config[CONF_BROKER]), - ) - return self.async_create_entry(title="", data={}) + return self.async_create_entry(data=options_config) birth = { **DEFAULT_BIRTH, - **current_config.get(CONF_BIRTH_MESSAGE, {}), + **options_config.get(CONF_BIRTH_MESSAGE, {}), } will = { **DEFAULT_WILL, - **current_config.get(CONF_WILL_MESSAGE, {}), + **options_config.get(CONF_WILL_MESSAGE, {}), } - discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) - discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) + discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() @@ -706,8 +660,8 @@ def _validate( fields[ vol.Optional( "birth_enable", - default=CONF_BIRTH_MESSAGE not in current_config - or current_config[CONF_BIRTH_MESSAGE] != {}, + default=CONF_BIRTH_MESSAGE not in options_config + or options_config[CONF_BIRTH_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -729,8 +683,8 @@ def _validate( fields[ vol.Optional( "will_enable", - default=CONF_WILL_MESSAGE not in current_config - or current_config[CONF_WILL_MESSAGE] != {}, + default=CONF_WILL_MESSAGE not in options_config + or options_config[CONF_WILL_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -814,11 +768,8 @@ async def _async_validate_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) - if ( - client_certificate_id - and not client_key_id - or not client_certificate_id - and client_key_id + if (client_certificate_id and not client_key_id) or ( + not client_certificate_id and client_key_id ): errors["base"] = "invalid_inclusion" return False @@ -828,14 +779,20 @@ async def _async_validate_broker_settings( # Return to form for file upload CA cert or client cert and key if ( - not client_certificate - and user_input.get(SET_CLIENT_CERT) - and not client_certificate_id - or not certificate - and user_input.get(SET_CA_CERT, "off") == "custom" - and not certificate_id - or user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS - and CONF_WS_PATH not in user_input + ( + not client_certificate + and user_input.get(SET_CLIENT_CERT) + and not client_certificate_id + ) + or ( + not certificate + and user_input.get(SET_CA_CERT, "off") == "custom" + and not certificate_id + ) + or ( + user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS + and CONF_WS_PATH not in user_input + ) ): return False diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 9f1c55a54e0c61..007b3b7e576f85 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,7 +4,7 @@ import jinja2 -from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" @@ -56,12 +56,15 @@ CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_ENTITY_PICTURE = "entity_picture" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MIN_KELVIN = "min_kelvin" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" @@ -160,6 +163,20 @@ PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" +CONFIG_ENTRY_VERSION = 1 +CONFIG_ENTRY_MINOR_VERSION = 2 + +# Split mqtt entry data and options +# Can be removed when config entry is bumped to version 2.1 +# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 +# From 2026.1 we will write version 2.1 +ENTRY_OPTION_FIELDS = ( + CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + "birth_message", + "will_message", +) + ENTITY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 8104c37574b2d4..7a17c1f34093b5 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -18,7 +18,6 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected -from .models import DATA_MQTT REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} @@ -45,11 +44,10 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - mqtt_instance = hass.data[DATA_MQTT].client - if TYPE_CHECKING: - assert mqtt_instance is not None - - redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) + redacted_config = { + "data": async_redact_data(dict(entry.data), REDACT_CONFIG), + "options": dict(entry.options), + } data = { "connected": is_connected(hass), diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a5ddb3ef4e6713..21d250db29addb 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -138,7 +138,10 @@ def get_origin_log_string( support_url_log = "" if include_url and (support_url := get_origin_support_url(discovery_payload)): support_url_log = f", support URL: {support_url}" - return f" from external application {origin_info["name"]}{sw_version_log}{support_url_log}" + return ( + " from external application " + f"{origin_info['name']}{sw_version_log}{support_url_log}" + ) @callback @@ -383,7 +386,7 @@ async def _async_component_setup( _async_add_component(discovery_payload) @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 + def async_discovery_message_received(msg: ReceiveMessage) -> None: """Process the received message.""" mqtt_data.last_discovery = msg.timestamp payload = msg.payload diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index d9812aaaf48529..f665f2c40167e4 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -151,7 +151,7 @@ def _event_received(self, msg: ReceiveMessage) -> None: ) except KeyError: _LOGGER.warning( - ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + "`event_type` missing in JSON event payload, '%s' on topic %s", payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 159a23d14d9480..eaaa80af2232e8 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,7 +51,10 @@ from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -182,6 +185,7 @@ vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -193,6 +197,8 @@ vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In( VALUES_ON_COMMAND_TYPE @@ -239,6 +245,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _topic: dict[str, str | None] _payload: dict[str, str] + _color_temp_kelvin: bool _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] @@ -263,16 +270,18 @@ def config_schema() -> VolSchemaType: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) + self._attr_effect_list = config.get(CONF_EFFECT_LIST) topic: dict[str, str | None] = { @@ -526,6 +535,9 @@ def _color_temp_received(self, msg: ReceiveMessage) -> None: if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP + if self._color_temp_kelvin: + self._attr_color_temp_kelvin = int(payload) + return self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( int(payload) ) @@ -575,7 +587,7 @@ def _xy_received(self, msg: ReceiveMessage) -> None: self._attr_xy_color = cast(tuple[float, float], xy_color) @callback - def _prepare_subscribe_topics(self) -> None: # noqa: C901 + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( @@ -818,7 +830,9 @@ def set_optimistic( ): ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] color_temp = ct_command_tpl( - color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ), None, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index f6efdd3281dc88..2d152ca12c87aa 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -61,7 +61,10 @@ from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -203,6 +206,7 @@ def _valid_color_configuration(config: ConfigType) -> ConfigType: # CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be # removed with HA Core 2025.3 vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional( @@ -216,6 +220,8 @@ def _valid_color_configuration(config: ConfigType) -> ConfigType: vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) @@ -275,15 +281,16 @@ def config_schema() -> VolSchemaType: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -381,7 +388,9 @@ def _update_color(self, values: dict[str, Any]) -> None: try: if color_mode == ColorMode.COLOR_TEMP: self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( values["color_temp"] ) ) @@ -486,7 +495,9 @@ def _state_received(self, msg: ReceiveMessage) -> None: self._attr_color_temp_kelvin = None else: self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( + values["color_temp"] # type: ignore[assignment] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( values["color_temp"] # type: ignore[arg-type] ) ) @@ -709,10 +720,13 @@ async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 should_update = True if ATTR_COLOR_TEMP_KELVIN in kwargs: - message["color_temp"] = color_util.color_temperature_kelvin_to_mired( + message["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) - if self._optimistic: self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 722bd864366728..69bc801ff1ebc3 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -39,7 +39,14 @@ from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from ..const import ( + CONF_COLOR_TEMP_KELVIN, + CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, @@ -85,12 +92,15 @@ { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_TEMPLATE): cv.template, vol.Optional(CONF_GREEN_TEMPLATE): cv.template, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), @@ -128,15 +138,16 @@ def config_schema() -> VolSchemaType: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -224,7 +235,9 @@ def _state_received(self, msg: ReceiveMessage) -> None: msg.payload ) self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin(int(color_temp)) + int(color_temp) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin(int(color_temp)) if color_temp != "None" else None ) @@ -310,8 +323,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP_KELVIN in kwargs: - values["color_temp"] = color_util.color_temperature_kelvin_to_mired( + values["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) if self._optimistic: diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 081449b142a666..25e98c01aafe24 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,6 +7,7 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", + "quality_scale": "platinum", "requirements": ["paho-mqtt==1.6.1"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b6392a..9b47a3ad23ae26 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -179,14 +179,14 @@ def _message_received(self, msg: ReceiveMessage) -> None: return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index 26ce8cb08dd699..b17812acd91112 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -89,10 +89,7 @@ rules: comment: > This is not possible because the integrations generates entities based on a user supplied config or discovery. - reconfiguration-flow: - status: done - comment: > - This integration can also be reconfigured via options flow. + reconfiguration-flow: done dynamic-devices: status: done comment: | @@ -126,6 +123,7 @@ rules: comment: | This integration does not use web sessions. strict-typing: - status: todo - comment: | - Requirement 'paho-mqtt==1.6.1' appears untyped + status: done + comment: > + Typing for 'paho-mqtt==1.6.1' supported via 'types-paho-mqtt==1.6.0.20240321' + (requirements_test.txt). diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 22f64053d23a0d..1cc5ba2d2e5e28 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -217,10 +217,7 @@ def _state_message_received(self, msg: ReceiveMessage) -> None: try: json_payload = json_loads_object(payload) _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ddc82a8dc10113..3815b6adbd5c22 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -201,7 +201,7 @@ "birth_payload": "The `birth` message that is published when MQTT is ready and connected.", "birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected", "birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.", - "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it loses the connection to your broker.", "will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.", "will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.", "will_qos": "The quality of service of the `will` message that is published by your MQTT broker.", diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 99b4e5cb821df3..59742d24b609c0 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -151,10 +151,7 @@ def _handle_state_message_received(self, msg: ReceiveMessage) -> None: rendered_json_payload = json_loads(payload) if isinstance(rendered_json_payload, dict): _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", rendered_json_payload, msg.topic, ) diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index fc50a2d654bf7b..b00924c97a5d75 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,11 +13,11 @@ from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -93,7 +93,7 @@ async def async_step_user( return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Mass server. diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index bd875d8a872035..5751d574e0452a 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -26,10 +26,8 @@ def find_matching_platform( if len(device_point.enum_values) > 0 and device_point.writable: return Platform.SELECT - if ( - description - and description.native_unit_of_measurement == "DM" - or (device_point.raw["maxValue"] and device_point.raw["minValue"]) + if (description and description.native_unit_of_measurement == "DM") or ( + device_point.raw["maxValue"] and device_point.raw["minValue"] ): if device_point.writable: return Platform.NUMBER diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 494ce9fdac03da..fa94971e2ef0bd 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -17,12 +17,12 @@ ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -138,7 +138,7 @@ async def async_step_credentials( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 27ef9a887feb29..253387c254afcf 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,11 +10,15 @@ from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -86,31 +90,31 @@ async def async_step_reauth( return await self.async_step_link() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Zeroconf discovery.""" _LOGGER.debug("Zeroconf discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit discovery.""" _LOGGER.debug("Homekit discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def _async_homekit_zeroconf_discovery_handler( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit and Zeroconf discovery.""" return await self._async_discovery_handler( discovery_info.host, discovery_info.name.replace(f".{discovery_info.type}", ""), - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + discovery_info.properties[ATTR_PROPERTIES_ID], ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf SSDP discovery.""" _LOGGER.debug("SSDP discovered: %s", discovery_info) diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index b8af8cd54db788..8b93ea10d79e63 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -14,9 +14,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "missing_internal_url": "Make sure Home Assistant has valid internal url", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant.", - "missing_status": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device.", + "missing_internal_url": "Make sure Home Assistant has a valid internal URL", + "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create new one with correct username/password." + "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 6f14c9c76bb21c..9c92724c5436b8 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -257,7 +257,6 @@ async def async_remove_config_entry_device( return not any( identifier for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] in modules + if (identifier[0] == DOMAIN and identifier[1] in modules) or identifier[1] in rooms ) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py new file mode 100644 index 00000000000000..7b2899c84aa9e8 --- /dev/null +++ b/homeassistant/components/netatmo/button.py @@ -0,0 +1,73 @@ +"""Support for Netatmo/Bubendorff button.""" + +from __future__ import annotations + +import logging + +from pyatmo import modules as NaModules + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo button platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCoverPreferredPositionButton(netatmo_device) + _LOGGER.debug("Adding button %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity) + ) + + +class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): + """Representation of a Netatmo cover preferred position button device.""" + + _attr_configuration_url = CONF_URL_CONTROL + _attr_entity_registry_enabled_default = False + _attr_translation_key = "preferred_position" + device: NaModules.Shutter + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device) + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self.home.entity_id, + SIGNAL_NAME: f"{HOME}-{self.home.entity_id}", + }, + ] + ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device_type}-preferred_position" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + # No state to update for button + + async def async_press(self) -> None: + """Handle button press to move the cover to a preferred position.""" + _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id) + await self.device.async_move_to_preferred_position() diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 74f2ebc84b2e6b..d69a62f37f95a2 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,6 +10,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, @@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 3a28c3b833616a..283ccc3740e3bd 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,6 +33,7 @@ DOMAIN, MANUFACTURER, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, @@ -350,7 +351,10 @@ def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None: NETATMO_CREATE_CAMERA_LIGHT, ], NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT], - NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER], + NetatmoDeviceCategory.shutter: [ + NETATMO_CREATE_COVER, + NETATMO_CREATE_BUTTON, + ], NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 71a8c5486229f4..9f3fe7174ff644 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -35,7 +35,7 @@ async def async_setup_entry( @callback def _create_entity(netatmo_device: NetatmoDevice) -> None: entity = NetatmoFan(netatmo_device) - _LOGGER.debug("Adding cover %s", entity) + _LOGGER.debug("Adding fan %s", entity) async_add_entities([entity]) entry.async_on_unload( diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json index 9f712e08f336a3..099c6aa17843c1 100644 --- a/homeassistant/components/netatmo/icons.json +++ b/homeassistant/components/netatmo/icons.json @@ -13,6 +13,11 @@ } } }, + "button": { + "preferred_position": { + "default": "mdi:window-shutter-auto" + } + }, "sensor": { "temp_trend": { "default": "mdi:trending-up" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 6b91aa204b2302..23b800e460d193 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,11 @@ } } }, + "button": { + "preferred_position": { + "name": "Preferred position" + } + }, "sensor": { "temp_trend": { "name": "Temperature trend" diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 965e3618645b1d..a0a5b76eee5698 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -9,7 +9,6 @@ from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,12 @@ CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_ipv4_address from .const import ( @@ -129,7 +134,7 @@ async def _show_setup_form( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Initialize flow from ssdp.""" updated_data: dict[str, str | int | bool] = {} @@ -144,10 +149,10 @@ async def async_step_ssdp( _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) - if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp: + if ATTR_UPNP_SERIAL not in discovery_info.upnp: return self.async_abort(reason="no_serial") - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) if device_url.scheme == "https": @@ -157,18 +162,14 @@ async def async_step_ssdp( updated_data[CONF_PORT] = DEFAULT_PORT for model in MODELS_PORT_80: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( - model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_80 for model in MODELS_PORT_5555: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( - model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_5555 updated_data[CONF_SSL] = True diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 40394677362c5a..f7a683326d3926 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -37,6 +37,7 @@ "RBR", "RBS", "RBW", + "RS", "LBK", "LBR", "CBK", diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 72087dd28db35e..4751e58a6a18a3 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -47,18 +47,21 @@ key="type", translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "link_rate": SensorEntityDescription( key="link_rate", translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "signal": SensorEntityDescription( key="signal", translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ssid": SensorEntityDescription( key="ssid", @@ -69,6 +72,7 @@ key="conn_ap_mac", translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } @@ -326,8 +330,6 @@ def new_device_callback() -> None: class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Representation of a device connected to a Netgear router.""" - _attr_entity_registry_enabled_default = False - def __init__( self, coordinator: DataUpdateCoordinator, diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index da3940117e9a90..291d4221d6c3ca 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -50,7 +50,7 @@ async def async_step_user( ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -92,7 +92,7 @@ async def async_step_reauth_confirm( ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 69d4e71c755c4f..80f47e56438878 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -112,7 +112,7 @@ def __init__( def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index d252a11b38e277..a75b0d72dca875 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.2"] + "requirements": ["nhc==0.3.4"] } diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812ece..06f94e0566f82f 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.9"] } diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 11013d471b5820..9972d41ac7b60a 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1 +1,45 @@ -"""The nmbs component.""" +"""The NMBS component.""" + +import logging + +from pyrail import iRail + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] + + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the NMBS component.""" + + api_client = iRail() + + hass.data.setdefault(DOMAIN, {}) + station_response = await hass.async_add_executor_job(api_client.get_stations) + if station_response == -1: + return False + hass.data[DOMAIN] = station_response["station"] + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NMBS from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py new file mode 100644 index 00000000000000..24ef8cd49954e6 --- /dev/null +++ b/homeassistant/components/nmbs/config_flow.py @@ -0,0 +1,180 @@ +"""Config flow for NMBS integration.""" + +from typing import Any + +from pyrail import iRail +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import Platform +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_EXCLUDE_VIAS, + CONF_SHOW_ON_MAP, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) + + +class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): + """NMBS config flow.""" + + def __init__(self) -> None: + """Initialize.""" + self.api_client = iRail() + self.stations: list[dict[str, Any]] = [] + + async def _fetch_stations(self) -> list[dict[str, Any]]: + """Fetch the stations.""" + stations_response = await self.hass.async_add_executor_job( + self.api_client.get_stations + ) + if stations_response == -1: + raise CannotConnect("The API is currently unavailable.") + return stations_response["station"] + + async def _fetch_stations_choices(self) -> list[SelectOptionDict]: + """Fetch the stations options.""" + + if len(self.stations) == 0: + self.stations = await self._fetch_stations() + + return [ + SelectOptionDict(value=station["id"], label=station["standardname"]) + for station in self.stations + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to setup a connection between 2 stations.""" + + try: + choices = await self._fetch_stations_choices() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + errors: dict = {} + if user_input is not None: + if user_input[CONF_STATION_FROM] == user_input[CONF_STATION_TO]: + errors["base"] = "same_station" + else: + [station_from] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_FROM] + ] + [station_to] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_TO] + ] + await self.async_set_unique_id( + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + ) + self._abort_if_unique_id_configured() + + config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + return self.async_create_entry( + title=config_entry_name, + data=user_input, + ) + + schema = vol.Schema( + { + vol.Required(CONF_STATION_FROM): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_STATION_TO): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_EXCLUDE_VIAS): BooleanSelector(), + vol.Optional(CONF_SHOW_ON_MAP): BooleanSelector(), + }, + ) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + try: + self.stations = await self._fetch_stations() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + station_from = None + station_to = None + station_live = None + for station in self.stations: + if user_input[CONF_STATION_FROM] in ( + station["standardname"], + station["name"], + ): + station_from = station + if user_input[CONF_STATION_TO] in ( + station["standardname"], + station["name"], + ): + station_to = station + if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( + station["standardname"], + station["name"], + ): + station_live = station + + if station_from is None or station_to is None: + return self.async_abort(reason="invalid_station") + if station_from == station_to: + return self.async_abort(reason="same_station") + + # config flow uses id and not the standard name + user_input[CONF_STATION_FROM] = station_from["id"] + user_input[CONF_STATION_TO] = station_to["id"] + + if station_live: + user_input[CONF_STATION_LIVE] = station_live["id"] + entity_registry = er.async_get(self.hass) + prefix = "live" + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + + return await self.async_step_user(user_input) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect to NMBS.""" diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py new file mode 100644 index 00000000000000..fddb7365501a1f --- /dev/null +++ b/homeassistant/components/nmbs/const.py @@ -0,0 +1,36 @@ +"""The NMBS integration.""" + +from typing import Final + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +DOMAIN: Final = "nmbs" + +PLATFORMS: Final = [Platform.SENSOR] + +CONF_STATION_FROM = "station_from" +CONF_STATION_TO = "station_to" +CONF_STATION_LIVE = "station_live" +CONF_EXCLUDE_VIAS = "exclude_vias" +CONF_SHOW_ON_MAP = "show_on_map" + + +def find_station_by_name(hass: HomeAssistant, station_name: str): + """Find given station_name in the station list.""" + return next( + ( + s + for s in hass.data[DOMAIN] + if station_name in (s["standardname"], s["name"]) + ), + None, + ) + + +def find_station(hass: HomeAssistant, station_name: str): + """Find given station_id in the station list.""" + return next( + (s for s in hass.data[DOMAIN] if station_name in s["id"]), + None, + ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index e17d1227bede25..9016eff11f8eef 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -1,7 +1,8 @@ { "domain": "nmbs", "name": "NMBS", - "codeowners": ["@thibmaek"], + "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", "loggers": ["pyrail"], diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 6ccdc742430a8e..85ae56144a0043 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from pyrail import iRail import voluptuous as vol @@ -11,19 +12,33 @@ PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, + CONF_PLATFORM, CONF_SHOW_ON_MAP, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import ( # noqa: F401 + CONF_EXCLUDE_VIAS, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, + PLATFORMS, + find_station, + find_station_by_name, +) + _LOGGER = logging.getLogger(__name__) API_FAILURE = -1 @@ -33,11 +48,6 @@ DEFAULT_ICON = "mdi:train" DEFAULT_ICON_ALERT = "mdi:alert-octagon" -CONF_STATION_FROM = "station_from" -CONF_STATION_TO = "station_to" -CONF_STATION_LIVE = "station_live" -CONF_EXCLUDE_VIAS = "exclude_vias" - PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_STATION_FROM): cv.string, @@ -73,33 +83,97 @@ def get_ride_duration(departure_time, arrival_time, delay=0): return duration_time + get_delay_in_minutes(delay) -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 NMBS sensor with iRail API.""" - api_client = iRail() - - name = config[CONF_NAME] - show_on_map = config[CONF_SHOW_ON_MAP] - station_from = config[CONF_STATION_FROM] - station_to = config[CONF_STATION_TO] - station_live = config.get(CONF_STATION_LIVE) - excl_vias = config[CONF_EXCLUDE_VIAS] + if config[CONF_PLATFORM] == DOMAIN: + if CONF_SHOW_ON_MAP not in config: + config[CONF_SHOW_ON_MAP] = False + if CONF_EXCLUDE_VIAS not in config: + config[CONF_EXCLUDE_VIAS] = False - sensors: list[SensorEntity] = [ - NMBSSensor(api_client, name, show_on_map, station_from, station_to, excl_vias) - ] + station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE] - if station_live is not None: - sensors.append( - NMBSLiveBoard(api_client, station_live, station_from, station_to) + for station_type in station_types: + station = ( + find_station_by_name(hass, config[station_type]) + if station_type in config + else None + ) + if station is None and station_type in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_station_not_found", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_station_not_found", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + "station_name": config[station_type], + "url": "/config/integrations/dashboard/add?domain=nmbs", + }, + ) + return + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - add_entities(sensors, True) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up NMBS sensor entities based on a config entry.""" + api_client = iRail() + + name = config_entry.data.get(CONF_NAME, None) + show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) + excl_vias = config_entry.data.get(CONF_EXCLUDE_VIAS, False) + + station_from = find_station(hass, config_entry.data[CONF_STATION_FROM]) + station_to = find_station(hass, config_entry.data[CONF_STATION_TO]) + + # setup the connection from station to station + # setup a disabled liveboard for both from and to station + async_add_entities( + [ + NMBSSensor( + api_client, name, show_on_map, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_from, station_from, station_to), + NMBSLiveBoard(api_client, station_to, station_from, station_to), + ] + ) class NMBSLiveBoard(SensorEntity): @@ -107,29 +181,39 @@ class NMBSLiveBoard(SensorEntity): _attr_attribution = "https://api.irail.be/" - def __init__(self, api_client, live_station, station_from, station_to): + def __init__( + self, + api_client: iRail, + live_station: dict[str, Any], + station_from: dict[str, Any], + station_to: dict[str, Any], + ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to - self._attrs = {} - self._state = None + self._attrs: dict[str, Any] | None = {} + self._state: str | None = None + + self.entity_registry_enabled_default = False @property - def name(self): + def name(self) -> str: """Return the sensor default name.""" - return f"NMBS Live ({self._station})" + return f"Trains in {self._station['standardname']}" @property - def unique_id(self): - """Return a unique ID.""" - unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = ( + f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" + ) return f"nmbs_live_{unique_id}" @property - def icon(self): + def icon(self) -> str: """Return the default icon or an alert icon if delays.""" if self._attrs and int(self._attrs["delay"]) > 0: return DEFAULT_ICON_ALERT @@ -137,12 +221,12 @@ def icon(self): return DEFAULT_ICON @property - def native_value(self): + def native_value(self) -> str | None: """Return sensor state.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -155,7 +239,7 @@ def extra_state_attributes(self): "departure_minutes": departure, "extra_train": int(self._attrs["isExtra"]) > 0, "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station, + "monitored_station": self._station["standardname"], } if delay > 0: @@ -166,7 +250,7 @@ def extra_state_attributes(self): def update(self) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station) + liveboard = self._api_client.get_liveboard(self._station["id"]) if liveboard == API_FAILURE: _LOGGER.warning("API failed in NMBSLiveBoard") @@ -195,8 +279,14 @@ class NMBSSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__( - self, api_client, name, show_on_map, station_from, station_to, excl_vias - ): + self, + api_client: iRail, + name: str, + show_on_map: bool, + station_from: dict[str, Any], + station_to: dict[str, Any], + excl_vias: bool, + ) -> None: """Initialize the NMBS connection sensor.""" self._name = name self._show_on_map = show_on_map @@ -205,16 +295,25 @@ def __init__( self._station_to = station_to self._excl_vias = excl_vias - self._attrs = {} + self._attrs: dict[str, Any] | None = {} self._state = None @property - def name(self): + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + + return f"nmbs_connection_{unique_id}" + + @property + def name(self) -> str: """Return the name of the sensor.""" + if self._name is None: + return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" return self._name @property - def icon(self): + def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) @@ -224,7 +323,7 @@ def icon(self): return "mdi:train" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -234,7 +333,7 @@ def extra_state_attributes(self): canceled = int(self._attrs["departure"]["canceled"]) attrs = { - "destination": self._station_to, + "destination": self._attrs["departure"]["station"], "direction": self._attrs["departure"]["direction"]["name"], "platform_arriving": self._attrs["arrival"]["platform"], "platform_departing": self._attrs["departure"]["platform"], @@ -271,12 +370,12 @@ def extra_state_attributes(self): return attrs @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the device.""" return self._state @property - def station_coordinates(self): + def station_coordinates(self) -> list[float]: """Get the lat, long coordinates for station.""" if self._state is None or not self._attrs: return [] @@ -286,7 +385,7 @@ def station_coordinates(self): return [latitude, longitude] @property - def is_via_connection(self): + def is_via_connection(self) -> bool: """Return whether the connection goes through another station.""" if not self._attrs: return False @@ -296,7 +395,7 @@ def is_via_connection(self): def update(self) -> None: """Set the state to the duration of a connection.""" connections = self._api_client.get_connections( - self._station_from, self._station_to + self._station_from["id"], self._station_to["id"] ) if connections == API_FAILURE: diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json new file mode 100644 index 00000000000000..3e7aa8d05bd6d3 --- /dev/null +++ b/homeassistant/components/nmbs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "api_unavailable": "The API is currently unavailable.", + "same_station": "[%key:component::nmbs::config::error::same_station%]", + "invalid_station": "Invalid station." + }, + "error": { + "same_station": "Departure and arrival station can not be the same." + }, + "step": { + "user": { + "data": { + "station_from": "Departure station", + "station_to": "Arrival station", + "exclude_vias": "Direct connections only", + "show_on_map": "Display on map" + }, + "data_description": { + "station_from": "Station where the train departs", + "station_to": "Station where the train arrives", + "exclude_vias": "Exclude connections with transfers", + "show_on_map": "Show the station on the map" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_station_not_found": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 4a9789c7e5134e..ac6771bb1bd96a 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -9,10 +9,10 @@ from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id @@ -75,7 +75,7 @@ async def async_step_user( return await self.async_step_validate(user_input) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" await self.async_set_unique_id(discovery_info.hostname[12:].upper()) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 9f4aef08aa9249..2f5ebcdb44cb00 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -68,8 +68,8 @@ "DEFAULT_MIN_VALUE", "DEFAULT_STEP", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "NumberDeviceClass", "NumberEntity", "NumberEntityDescription", diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91a9d6adfe4ce4..1a9c6c91ca7a6e 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -363,7 +363,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 966c51e98e9d03..b1b44966d14586 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,7 +9,6 @@ from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import PyNUTData from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -95,7 +95,7 @@ def __init__(self) -> None: self.reauth_entry: ConfigEntry | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered nut device.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index ec5905fc16c446..83b8d340dc1677 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -113,15 +113,15 @@ "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, - "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_l1_realpower": { - "name": "Current input bypass L1 real power" + "name": "Input bypass L1 real power" }, "input_bypass_l2_realpower": { - "name": "Current input bypass L2 real power" + "name": "Input bypass L2 real power" }, "input_bypass_l3_realpower": { - "name": "Current input bypass L3 real power" + "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, "input_l1_current": { "name": "Input L1 current" }, @@ -134,10 +134,10 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, - "input_realpower": { "name": "Current input real power" }, - "input_l1_realpower": { "name": "Current input L1 real power" }, - "input_l2_realpower": { "name": "Current input L2 real power" }, - "input_l3_realpower": { "name": "Current input L3 real power" }, + "input_realpower": { "name": "Input real power" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_realpower": { "name": "Input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -160,11 +160,11 @@ "output_l1_power_percent": { "name": "Output L1 power usage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, - "output_realpower": { "name": "Current output real power" }, + "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Current output L1 real power" }, - "output_l2_realpower": { "name": "Current output L2 real power" }, - "output_l3_realpower": { "name": "Current output L3 real power" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "output_l1_n_voltage": { "name": "Output L1-N voltage" }, @@ -183,9 +183,9 @@ "ups_id": { "name": "System identifier" }, "ups_load": { "name": "Load" }, "ups_load_high": { "name": "Overload setting" }, - "ups_power": { "name": "Current apparent power" }, + "ups_power": { "name": "Apparent power" }, "ups_power_nominal": { "name": "Nominal power" }, - "ups_realpower": { "name": "Current real power" }, + "ups_realpower": { "name": "Real power" }, "ups_realpower_nominal": { "name": "Nominal real power" }, "ups_shutdown": { "name": "Shutdown ability" }, "ups_start_auto": { "name": "Start on ac" }, diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 559900db5d0f7f..03f6348ebac0c6 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -8,11 +8,11 @@ from pyobihai import PyObihai import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -54,7 +54,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 2 discovery_schema: vol.Schema | None = None - _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None + _dhcp_discovery_info: DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,7 +94,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Obihai.""" diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 9bbf21d71fa46a..627ca999acd834 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -12,7 +12,6 @@ import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -26,6 +25,8 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -167,7 +168,7 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu return await self.async_step_user(import_data) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery flow.""" uuid = discovery_info.properties["uuid"] @@ -193,7 +194,7 @@ async def async_step_zeroconf( return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discovery flow.""" uuid = discovery_info.upnp["UDN"][5:] diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index ff00b6c34208f4..c6d7373a002a7c 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -80,7 +80,7 @@ def device_info(self) -> DeviceInfo: """Device info.""" unique_id = cast(str, self.config_entry.unique_id) configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + scheme=(self.config_entry.data[CONF_SSL] and "https") or "http", host=self.config_entry.data[CONF_HOST], port=self.config_entry.data[CONF_PORT], path=self.config_entry.data[CONF_PATH], diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 3bcba567803a0b..6983db73cf496b 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -28,12 +28,12 @@ _LOGGER = logging.getLogger(__name__) __all__ = [ - "CONF_URL", - "CONF_PROMPT", - "CONF_MODEL", + "CONF_KEEP_ALIVE", "CONF_MAX_HISTORY", + "CONF_MODEL", "CONF_NUM_CTX", - "CONF_KEEP_ALIVE", + "CONF_PROMPT", + "CONF_URL", "DOMAIN", ] diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d62965f8..857f0bff34a2a5 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 3c4aac2cd7df39..c77d87d91b926c 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -4,32 +4,40 @@ from pyownet import protocol -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, PLATFORMS -from .onewirehub import CannotConnect, OneWireHub +from .const import DOMAIN +from .onewirehub import OneWireConfigEntry, OneWireHub _LOGGER = logging.getLogger(__name__) -type OneWireConfigEntry = ConfigEntry[OneWireHub] + +_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" - onewire_hub = OneWireHub(hass) + onewire_hub = OneWireHub(hass, entry) try: - await onewire_hub.initialize(entry) + await onewire_hub.initialize() except ( - CannotConnect, # Failed to connect to the server + protocol.ConnError, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc entry.runtime_data = onewire_hub - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + onewire_hub.schedule_scan_for_new_devices() entry.async_on_unload(entry.add_update_listener(options_update_listener)) @@ -50,7 +58,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) async def options_update_listener( diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5607fd7ed1d00a..60a1d165b154c6 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from homeassistant.components.binary_sensor import ( @@ -12,12 +13,22 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclass(frozen=True) @@ -93,19 +104,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireBinarySensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireBinarySensor] = [] - for device in onewire_hub.devices: + entities: list[OneWireBinarySensorEntity] = [] + for device in devices: family = device.family device_id = device.id device_type = device.type @@ -120,7 +140,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireBinarySensor( + OneWireBinarySensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -132,7 +152,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: return entities -class OneWireBinarySensor(OneWireEntity, BinarySensorEntity): +class OneWireBinarySensorEntity(OneWireEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" entity_description: OneWireBinarySensorEntityDescription diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 3889db2a069356..e40e99d090363b 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,18 +5,16 @@ from copy import deepcopy from typing import Any +from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_HOST, @@ -29,7 +27,7 @@ OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, ) -from .onewirehub import CannotConnect, OneWireHub +from .onewirehub import OneWireConfigEntry DATA_SCHEMA = vol.Schema( { @@ -39,70 +37,123 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - hub = OneWireHub(hass) - - host = data[CONF_HOST] - port = data[CONF_PORT] - # Raises CannotConnect exception on failure - await hub.connect(host, port) - - # Return info that you want to store in the config entry. - return {"title": host} +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] +) -> None: + """Validate the user input allows us to connect.""" + try: + await hass.async_add_executor_job( + protocol.proxy, data[CONF_HOST], data[CONF_PORT] + ) + except protocol.ConnError: + errors["base"] = "cannot_connect" class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): """Handle 1-Wire config flow.""" VERSION = 1 - - def __init__(self) -> None: - """Initialize 1-Wire config flow.""" - self.onewire_config: dict[str, Any] = {} + _discovery_data: dict[str, Any] async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle 1-Wire config flow start. + """Handle 1-Wire config flow start.""" + errors: dict[str, str] = {} + if user_input: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + await validate_input(self.hass, user_input, errors) + if not errors: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) - Let user manually input configuration. - """ + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle 1-Wire reconfiguration.""" errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() if user_input: - # Prevent duplicate entries self._async_abort_entries_match( - { - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) - self.onewire_config.update(user_input) + await validate_input(self.hass, user_input, errors) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, reconfigure_entry.data | (user_input or {}) + ), + description_placeholders={"name": reconfigure_entry.title}, + errors=errors, + ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle hassio discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.config["addon"], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + } + return await self.async_step_discovery_confirm() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + await self._async_handle_discovery_without_unique_id() - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - else: + self._discovery_data = { + "title": discovery_info.name, + CONF_HOST: discovery_info.hostname, + CONF_PORT: discovery_info.port, + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + data = { + CONF_HOST: self._discovery_data[CONF_HOST], + CONF_PORT: self._discovery_data[CONF_PORT], + } + await validate_input(self.hass, data, errors) + if not errors: return self.async_create_entry( - title=info["title"], data=self.onewire_config + title=self._discovery_data["title"], data=data ) return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, + step_id="discovery_confirm", errors=errors, ) @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: OneWireConfigEntry, ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" return OnewireOptionsFlowHandler(config_entry) @@ -126,7 +177,7 @@ class OnewireOptionsFlowHandler(OptionsFlow): current_device: str """Friendly name of the currently selected device.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: OneWireConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index a4f3ebe9a78e87..2ab44c47892f3f 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -2,8 +2,6 @@ from __future__ import annotations -from homeassistant.const import Platform - DEFAULT_HOST = "localhost" DEFAULT_PORT = 4304 @@ -54,9 +52,3 @@ READ_MODE_BOOL = "bool" READ_MODE_FLOAT = "float" READ_MODE_INT = "int" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, - Platform.SWITCH, -] diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index 523bb4e258006e..48426cf3b5bd90 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import OneWireConfigEntry +from .onewirehub import OneWireConfigEntry TO_REDACT = {CONF_HOST} diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index bbf36deaaa0aea..2ea21aca48818f 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -54,6 +54,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the entity.""" return { "device_file": self._device_file, + # raw_value attribute is deprecated and can be removed in 2025.8 "raw_value": self._value_raw, } @@ -84,4 +85,4 @@ def update(self) -> None: elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: - self._state = round(self._value_raw, 1) + self._state = self._value_raw diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 4f3cb5d04ab5d9..844c4c1afb9935 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"] + "requirements": ["pyownet==0.10.0.post1"], + "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 2dc617ba039f15..a8d8dd0603444b 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,26 +2,20 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging import os -from typing import TYPE_CHECKING from pyownet import protocol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_VIA_DEVICE, - CONF_HOST, - CONF_PORT, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.signal_type import SignalType from .const import ( DEVICE_SUPPORT, @@ -42,8 +36,15 @@ "EF": MANUFACTURER_HOBBYBOARDS, } +_DEVICE_SCAN_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) +type OneWireConfigEntry = ConfigEntry[OneWireHub] + +SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]]( + f"{DOMAIN}_new_device_connected" +) + def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" @@ -55,116 +56,119 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - def __init__(self, hass: HomeAssistant) -> None: + owproxy: protocol._Proxy + devices: list[OWDeviceDescription] + + def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" - self.hass = hass - self.owproxy: protocol._Proxy | None = None - self.devices: list[OWDeviceDescription] | None = None - - async def connect(self, host: str, port: int) -> None: - """Connect to the server.""" - try: - self.owproxy = await self.hass.async_add_executor_job( - protocol.proxy, host, port - ) - except protocol.ConnError as exc: - raise CannotConnect from exc + self._hass = hass + self._config_entry = config_entry - async def initialize(self, config_entry: ConfigEntry) -> None: - """Initialize a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] + def _initialize(self) -> None: + """Connect to the server, and discover connected devices. + + Needs to be run in executor. + """ + host = self._config_entry.data[CONF_HOST] + port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - await self.connect(host, port) - await self.discover_devices() - if TYPE_CHECKING: - assert self.devices - # Register discovered devices on Hub - device_registry = dr.async_get(self.hass) - for device in self.devices: - device_info: DeviceInfo = device.device_info + self.owproxy = protocol.proxy(host, port) + self.devices = _discover_devices(self.owproxy) + + async def initialize(self) -> None: + """Initialize a config entry.""" + await self._hass.async_add_executor_job(self._initialize) + self._populate_device_registry(self.devices) + + @callback + def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None: + """Populate the device registry.""" + device_registry = dr.async_get(self._hass) + for device in devices: device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers=device_info[ATTR_IDENTIFIERS], - manufacturer=device_info[ATTR_MANUFACTURER], - model=device_info[ATTR_MODEL], - name=device_info[ATTR_NAME], - via_device=device_info.get(ATTR_VIA_DEVICE), + config_entry_id=self._config_entry.entry_id, + **device.device_info, ) - async def discover_devices(self) -> None: - """Discover all devices.""" - if self.devices is None: - self.devices = await self.hass.async_add_executor_job( - self._discover_devices + def schedule_scan_for_new_devices(self) -> None: + """Schedule a regular scan of the bus for new devices.""" + self._config_entry.async_on_unload( + async_track_time_interval( + self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL + ) + ) + + async def _scan_for_new_devices(self, _: datetime) -> None: + """Scan the bus for new devices.""" + devices = await self._hass.async_add_executor_job( + _discover_devices, self.owproxy + ) + existing_device_ids = [device.id for device in self.devices] + new_devices = [ + device for device in devices if device.id not in existing_device_ids + ] + if new_devices: + self.devices.extend(new_devices) + self._populate_device_registry(new_devices) + async_dispatcher_send( + self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices ) - def _discover_devices( - self, path: str = "/", parent_id: str | None = None - ) -> list[OWDeviceDescription]: - """Discover all server devices.""" - devices: list[OWDeviceDescription] = [] - assert self.owproxy - for device_path in self.owproxy.dir(path): - device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = self.owproxy.read(f"{device_path}family").decode() - _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = self._get_device_type(device_path) - if not _is_known_device(device_family, device_type): - _LOGGER.warning( - "Ignoring unknown device family/type (%s/%s) found for device %s", - device_family, - device_type, - device_id, - ) - continue - device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, - ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( - device_family, MANUFACTURER_MAXIM - ), - ATTR_MODEL: device_type, - ATTR_NAME: device_id, - } - if parent_id: - device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) - device = OWDeviceDescription( - device_info=device_info, - id=device_id, - family=device_family, - path=device_path, - type=device_type, + +def _discover_devices( + owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +) -> list[OWDeviceDescription]: + """Discover all server devices.""" + devices: list[OWDeviceDescription] = [] + for device_path in owproxy.dir(path): + device_id = os.path.split(os.path.split(device_path)[0])[1] + device_family = owproxy.read(f"{device_path}family").decode() + _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) + device_type = _get_device_type(owproxy, device_path) + if not _is_known_device(device_family, device_type): + _LOGGER.warning( + "Ignoring unknown device family/type (%s/%s) found for device %s", + device_family, + device_type, + device_id, ) - devices.append(device) - if device_branches := DEVICE_COUPLERS.get(device_family): - for branch in device_branches: - devices += self._discover_devices( - f"{device_path}{branch}", device_id - ) - - return devices - - def _get_device_type(self, device_path: str) -> str | None: - """Get device model.""" - if TYPE_CHECKING: - assert self.owproxy - try: - device_type = self.owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: - _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) - return None - _LOGGER.debug("read `%stype`: %s", device_path, device_type) - if device_type == "EDS": - device_type = self.owproxy.read(f"{device_path}device_type").decode() - _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) - if TYPE_CHECKING: - assert isinstance(device_type, str) - return device_type - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidPath(HomeAssistantError): - """Error to indicate the path is invalid.""" + continue + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM), + model=device_type, + model_id=device_type, + name=device_id, + serial_number=device_id[3:], + ) + if parent_id: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) + device = OWDeviceDescription( + device_info=device_info, + id=device_id, + family=device_family, + path=device_path, + type=device_type, + ) + devices.append(device) + if device_branches := DEVICE_COUPLERS.get(device_family): + for branch in device_branches: + devices += _discover_devices( + owproxy, f"{device_path}{branch}", device_id + ) + + return devices + + +def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: + """Get device model.""" + try: + device_type: str = owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None + _LOGGER.debug("read `%stype`: %s", device_path, device_type) + if device_type == "EDS": + device_type = owproxy.read(f"{device_path}device_type").decode() + _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) + return device_type diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml new file mode 100644 index 00000000000000..d46ed69f0d663e --- /dev/null +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -0,0 +1,126 @@ +rules: + ## Bronze + config-flow: + status: todo + comment: missing data_description on options flow + test-before-configure: done + unique-config-entry: + status: done + comment: unique ID is not available, but duplicates are prevented based on host/port + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: entities do not subscribe to events + dependency-transparency: + status: todo + comment: The package is not built and published inside a CI pipeline + action-setup: + status: exempt + comment: No service actions currently available + common-modules: + status: done + comment: base entity available, but no coordinator + docs-high-level-description: + status: todo + comment: Under review + docs-installation-instructions: + status: todo + comment: Under review + docs-removal-instructions: + status: todo + comment: Under review + docs-actions: + status: todo + comment: Under review + brands: done + + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions currently available + reauthentication-flow: + status: exempt + comment: Local polling without authentication + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Under review + docs-configuration-parameters: + status: todo + comment: Under review + + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: done + comment: hassio and mDNS/zeroconf discovery implemented + stale-devices: + status: done + comment: > + Manual removal, as it is not possible to distinguish + between a flaky device and a device that has been removed + diagnostics: + status: todo + comment: config-entry diagnostics level available, might be nice to have device-level diagnostics + exception-translations: + status: todo + comment: Under review + icon-translations: + status: exempt + comment: It doesn't make sense to override defaults + reconfiguration-flow: done + dynamic-devices: + status: done + comment: The bus is scanned for new devices at regular interval + discovery-update-info: + status: todo + comment: Under review + repair-issues: + status: exempt + comment: No repairs available + docs-use-cases: + status: todo + comment: Under review + docs-supported-devices: + status: todo + comment: Under review + docs-supported-functions: + status: todo + comment: Under review + docs-data-update: + status: todo + comment: Under review + docs-known-limitations: + status: todo + comment: Under review + docs-troubleshooting: + status: todo + comment: Under review + docs-examples: + status: todo + comment: Under review + + ## Platinum + async-dependency: + status: todo + comment: The dependency is not async + inject-websession: + status: exempt + comment: No websession + strict-typing: + status: todo + comment: The dependency is not typed diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py new file mode 100644 index 00000000000000..7a26ecdbb31737 --- /dev/null +++ b/homeassistant/components/onewire/select.py @@ -0,0 +1,110 @@ +"""Support for 1-Wire environment select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import os + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import READ_MODE_INT +from .entity import OneWireEntity, OneWireEntityDescription +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + + +@dataclass(frozen=True) +class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription): + """Class describing OneWire select entities.""" + + +ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = { + "28": ( + OneWireSelectEntityDescription( + key="tempres", + entity_category=EntityCategory.CONFIG, + read_mode=READ_MODE_INT, + options=["9", "10", "11", "12"], + translation_key="tempres", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OneWireConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up 1-Wire platform.""" + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) + + +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSelectEntity]: + """Get a list of entities.""" + entities: list[OneWireSelectEntity] = [] + + for device in devices: + family = device.family + device_id = device.id + device_info = device.device_info + + if family not in ENTITY_DESCRIPTIONS: + continue + for description in ENTITY_DESCRIPTIONS[family]: + device_file = os.path.join(os.path.split(device.path)[0], description.key) + entities.append( + OneWireSelectEntity( + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + owproxy=onewire_hub.owproxy, + ) + ) + + return entities + + +class OneWireSelectEntity(OneWireEntity, SelectEntity): + """Implementation of a 1-Wire switch.""" + + entity_description: OneWireSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return str(self._state) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 2dca53af1cfc9d..1c4047abf0a593 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Mapping import dataclasses +from datetime import timedelta import logging import os from types import MappingProxyType @@ -25,10 +26,10 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import OneWireConfigEntry from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, @@ -39,7 +40,17 @@ READ_MODE_INT, ) from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclasses.dataclass(frozen=True) @@ -352,22 +363,35 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data, config_entry.options + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + # note: we have to go through the executor as SENSOR platform + # makes extra calls to the hub during device listing + entities = await hass.async_add_executor_job( + get_entities, hub, devices, config_entry.options + ) + async_add_entities(entities, True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) def get_entities( - onewire_hub: OneWireHub, options: MappingProxyType[str, Any] -) -> list[OneWireSensor]: + onewire_hub: OneWireHub, + devices: list[OWDeviceDescription], + options: MappingProxyType[str, Any], +) -> list[OneWireSensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireSensor] = [] - assert onewire_hub.owproxy - for device in onewire_hub.devices: + entities: list[OneWireSensorEntity] = [] + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -421,7 +445,7 @@ def get_entities( ) continue entities.append( - OneWireSensor( + OneWireSensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -432,7 +456,7 @@ def get_entities( return entities -class OneWireSensor(OneWireEntity, SensorEntity): +class OneWireSensorEntity(OneWireEntity, SensorEntity): """Implementation of a 1-Wire sensor.""" entity_description: OneWireSensorEntityDescription diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 68585c3203f7ca..9613a927f8de10 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -1,21 +1,34 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::onewire::config::step::user::data_description::host%]", + "port": "[%key:component::onewire::config::step::user::data_description::port%]" + }, + "description": "Update OWServer configuration for {name}" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of your 1-Wire device." + "host": "The hostname or IP address of your OWServer instance.", + "port": "The port of your OWServer instance (default is 4304)." }, - "title": "Set server details" + "title": "Set OWServer instance details" } } }, @@ -28,6 +41,17 @@ "name": "Hub short on branch {id}" } }, + "select": { + "tempres": { + "name": "Temperature resolution", + "state": { + "9": "9 bits (0.5°C, fastest, up to 93.75ms)", + "10": "10 bits (0.25°C, up to 187.5ms)", + "11": "11 bits (0.125°C, up to 375ms)", + "12": "12 bits (0.0625°C, slowest, up to 750ms)" + } + } + }, "sensor": { "counter_id": { "name": "Counter {id}" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index ec0bc44e03fee5..7215b1ec020444 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -3,18 +3,29 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclass(frozen=True) @@ -153,20 +164,29 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSwitchEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireSwitch] = [] + entities: list[OneWireSwitchEntity] = [] - for device in onewire_hub.devices: + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -184,7 +204,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireSwitch( + OneWireSwitchEntity( description=description, device_id=device_id, device_file=device_file, @@ -196,7 +216,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: return entities -class OneWireSwitch(OneWireEntity, SwitchEntity): +class OneWireSwitchEntity(OneWireEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" entity_description: OneWireSwitchEntityDescription diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index a484b3aaa049db..974b4082cae63f 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -6,7 +6,6 @@ import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -26,6 +25,7 @@ SelectSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_RECEIVER_MAX_VOLUME, @@ -168,7 +168,7 @@ async def async_step_eiscp_discovery( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle flow initialized by SSDP discovery.""" _LOGGER.debug("Config flow start ssdp: %s", discovery_info) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 66e566af0bf906..fc5de57508b168 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -15,7 +15,6 @@ from wsdiscovery.service import Service from zeep.exceptions import Fault -from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -39,6 +38,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_DEVICE_ID, @@ -170,7 +170,7 @@ async def async_step_reauth_confirm( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" hass = self.hass diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686b2a..f15f6637ab942c 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -263,16 +263,22 @@ async def async_check_date_and_time(self) -> None: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 4b5335f1eb6c62..b7b34f7be9f759 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -252,9 +252,9 @@ def __init__(self, event_manager: EventManager) -> None: async def async_start(self) -> bool: """Start pullpoint subscription.""" - assert ( - self.state == PullPointManagerState.STOPPED - ), "PullPoint manager already started" + assert self.state == PullPointManagerState.STOPPED, ( + "PullPoint manager already started" + ) LOGGER.debug("%s: Starting PullPoint manager", self._name) if not await self._async_start_pullpoint(): self.state = PullPointManagerState.FAILED @@ -501,9 +501,9 @@ def __init__(self, event_manager: EventManager) -> None: async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) - assert ( - self.state == WebHookManagerState.STOPPED - ), "Webhook manager already started" + assert self.state == WebHookManagerState.STOPPED, ( + "Webhook manager already started" + ) assert self._webhook_url is None, "Webhook already registered" self._async_register_webhook() if not await self._async_start_webhook(): diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b678792a..9d27314593cb16 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,12 +1,12 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm"], + "codeowners": ["@hunterjm", "@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index d7bbaa4fb3fa44..9904a4bbfa93b6 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +import dataclasses import datetime from typing import Any @@ -370,22 +371,56 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None -@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +_TAPO_EVENT_TEMPLATES: dict[str, Event] = { + "IsVehicle": Event( + uid="", + name="Vehicle Detection", + platform="binary_sensor", + device_class="motion", + ), + "IsPeople": Event( + uid="", name="Person Detection", platform="binary_sensor", device_class="motion" + ), + "IsLineCross": Event( + uid="", + name="Line Detector Crossed", + platform="binary_sensor", + device_class="motion", + ), + "IsTamper": Event( + uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" + ), + "IsIntrusion": Event( + uid="", + name="Intrusion Detection", + platform="binary_sensor", + device_class="safety", + ), +} + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") @PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") async def async_parse_tplink_detector(uid: str, msg) -> Event | None: """Handle parsing tplink smart event messages. - Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + Topic: tns1:RuleEngine/CellMotionDetector/Intrusion + Topic: tns1:RuleEngine/CellMotionDetector/LineCross + Topic: tns1:RuleEngine/CellMotionDetector/People + Topic: tns1:RuleEngine/CellMotionDetector/Tamper + Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent Topic: tns1:RuleEngine/PeopleDetector/People + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - video_source = "" - video_analytics = "" - rule = "" - topic = "" - vehicle = False - person = False - enabled = False try: + video_source = "" + video_analytics = "" + rule = "" topic, payload = extract_message(msg) for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": @@ -396,34 +431,19 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: rule = source.Value for item in payload.Data.SimpleItem: - if item.Name == "IsVehicle": - vehicle = True - enabled = item.Value == "true" - if item.Name == "IsPeople": - person = True - enabled = item.Value == "true" + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue + + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) + except (AttributeError, KeyError): return None - if vehicle: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - if person: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Person Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - return None diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 9c73766c8d48e0..c89574bf3bd715 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from collections.abc import Callable import json -from typing import Any, Literal +from typing import Any, Literal, cast import openai from openai._types import NOT_GIVEN @@ -11,10 +11,8 @@ ChatCompletionMessage, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, ChatCompletionToolParam, - ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition @@ -26,10 +24,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid from . import OpenAIConfigEntry from .const import ( @@ -73,6 +70,30 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) +def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam: + """Convert from class to TypedDict.""" + tool_calls: list[ChatCompletionMessageToolCallParam] = [] + if message.tool_calls: + tool_calls = [ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=tool_call.function.arguments, + name=tool_call.function.name, + ), + type=tool_call.type, + ) + for tool_call in message.tool_calls + ] + param = ChatCompletionAssistantMessageParam( + role=message.role, + content=message.content, + ) + if tool_calls: + param["tool_calls"] = tool_calls + return param + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -84,7 +105,6 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[ChatCompletionMessageParam]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -123,114 +143,56 @@ async def async_process( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_call_api(user_input, session) + + async def _async_call_api( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[ChatCompletionMessageParam], + ) -> conversation.ConversationResult: + """Call the API.""" options = self.entry.options - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[ChatCompletionToolParam] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - - if options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Error preparing LLM API", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] - - if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() - messages = [] - - elif user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - messages = [] - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + await session.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), ) + except conversation.ConverseError as err: + return err.as_conversation_result() - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - prompt = "\n".join(prompt_parts) + tools: list[ChatCompletionToolParam] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools + ] - # Create a copy of the variable because we attach it to the trace - messages = [ - ChatCompletionSystemMessageParam(role="system", content=prompt), - *messages[1:], - ChatCompletionUserMessageParam(role="user", content=user_input.text), - ] + messages: list[ChatCompletionMessageParam] = [] + for message in session.async_get_messages(user_input.agent_id): + if message.native is not None and message.agent_id == user_input.agent_id: + messages.append(message.native) + else: + messages.append( + cast( + ChatCompletionMessageParam, + {"role": message.role, "content": message.content}, + ) + ) LOGGER.debug("Prompt: %s", messages) LOGGER.debug("Tools: %s", tools) trace.async_conversation_trace_append( trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": messages, "tools": llm_api.tools if llm_api else None}, + { + "messages": session.messages, + "tools": session.llm_api.tools if session.llm_api else None, + }, ) client = self.entry.runtime_data @@ -245,7 +207,7 @@ async def async_process( max_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=conversation_id, + user=session.conversation_id, ) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) @@ -255,44 +217,26 @@ async def async_process( "Sorry, I had a problem talking to OpenAI", ) return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) LOGGER.debug("Response %s", result) response = result.choices[0].message + messages.append(_message_convert(response)) + + session.async_add_message( + conversation.ChatMessage( + role=response.role, + agent_id=user_input.agent_id, + content=response.content or "", + native=messages[-1], + ), + ) - def message_convert( - message: ChatCompletionMessage, - ) -> ChatCompletionMessageParam: - """Convert from class to TypedDict.""" - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - if message.tool_calls: - tool_calls = [ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=tool_call.function.arguments, - name=tool_call.function.name, - ), - type=tool_call.type, - ) - for tool_call in message.tool_calls - ] - param = ChatCompletionAssistantMessageParam( - role=message.role, - content=message.content, - ) - if tool_calls: - param["tool_calls"] = tool_calls - return param - - messages.append(message_convert(response)) - tool_calls = response.tool_calls - - if not tool_calls or not llm_api: + if not response.tool_calls or not session.llm_api: break - for tool_call in tool_calls: + for tool_call in response.tool_calls: tool_input = llm.ToolInput( tool_name=tool_call.function.name, tool_args=json.loads(tool_call.function.arguments), @@ -302,7 +246,7 @@ def message_convert( ) try: - tool_response = await llm_api.async_call_tool(tool_input) + tool_response = await session.llm_api.async_call_tool(tool_input) except (HomeAssistantError, vol.Invalid) as e: tool_response = {"error": type(e).__name__} if str(e): @@ -316,13 +260,19 @@ def message_convert( content=json.dumps(tool_response), ) ) - - self.history[conversation_id] = messages + session.async_add_message( + conversation.ChatMessage( + role="native", + agent_id=user_input.agent_id, + content="", + native=messages[-1], + ) + ) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py index b495819211ba3b..9cd6a79f0129b6 100644 --- a/homeassistant/components/openhome/config_flow.py +++ b/homeassistant/components/openhome/config_flow.py @@ -3,13 +3,13 @@ import logging from typing import Any -from homeassistant.components.ssdp import ( +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index c9143c977cebf9..8c903c90bbb2fa 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -67,11 +67,9 @@ async def async_setup_entry( ] -def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> ( - Callable[ - [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] - ] -): +def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> Callable[ + [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] +]: """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 77c7e3ab40a1f1..405af126c03d70 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -15,7 +15,7 @@ }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "id_exists": "Gateway id already exists", + "id_exists": "Gateway ID already exists", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } @@ -379,7 +379,7 @@ "fields": { "gateway_id": { "name": "Gateway ID", - "description": "The gateway_id of the OpenTherm Gateway." + "description": "The ID of the OpenTherm Gateway." } } }, diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 1a34d0547aa965..136a1a4e57aa29 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -6,27 +6,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" coordinator = OpowerCoordinator(hass, entry.data) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 629dce0823c400..f6f3524d630875 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,9 +5,11 @@ from types import MappingProxyType from typing import Any, cast +import aiohttp from opower import ( Account, AggregateType, + CannotConnect, CostRead, Forecast, InvalidAuth, @@ -27,7 +29,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN @@ -80,8 +82,16 @@ async def _async_update_data( # assume previous session has expired and re-login. await self.api.async_login() except InvalidAuth as err: + _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err - forecasts: list[Forecast] = await self.api.async_get_forecast() + except CannotConnect as err: + _LOGGER.error("Error during login: %s", err) + raise UpdateFailed(f"Error during login: {err}") from err + try: + forecasts: list[Forecast] = await self.api.async_get_forecast() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise _LOGGER.debug("Updating sensor data with: %s", forecasts) # Because Opower provides historical usage/cost with a delay of a couple of days # we need to insert data into statistics. @@ -90,7 +100,12 @@ async def _async_update_data( async def _insert_statistics(self) -> None: """Insert Opower statistics.""" - for account in await self.api.async_get_accounts(): + try: + accounts = await self.api.async_get_accounts() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting accounts: %s", err) + raise + for account in accounts: id_prefix = "_".join( ( self.api.utility.subdomain(), @@ -252,9 +267,13 @@ def _update_with_finer_cost_reads( start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30) end = dt_util.now(tz) _LOGGER.debug("Getting monthly cost reads: %s - %s", start, end) - cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.BILL, start, end - ) + try: + cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting monthly cost reads: %s", err) + raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) if account.read_resolution == ReadResolution.BILLING: return cost_reads @@ -267,9 +286,13 @@ def _update_with_finer_cost_reads( assert start start = max(start, end - timedelta(days=3 * 365)) _LOGGER.debug("Getting daily cost reads: %s - %s", start, end) - daily_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.DAY, start, end - ) + try: + daily_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting daily cost reads: %s", err) + raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: @@ -281,9 +304,13 @@ def _update_with_finer_cost_reads( assert start start = max(start, end - timedelta(days=2 * 30)) _LOGGER.debug("Getting hourly cost reads: %s - %s", start, end) - hourly_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.HOUR, start, end - ) + try: + hourly_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting hourly cost reads: %s", err) + raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) _LOGGER.debug("Got %s cost reads", len(cost_reads)) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 05a22dfbf1b08f..7f8eb22d1e645c 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,6 +20,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import OpowerConfigEntry from .const import DOMAIN from .coordinator import OpowerCoordinator @@ -183,11 +183,13 @@ class OpowerEntityDescription(SensorEntityDescription): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: OpowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Opower sensor.""" - coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[OpowerSensor] = [] forecasts = coordinator.data.values() for forecast in forecasts: diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index ca0faa160f0298..f4029f4aa9e2bb 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0"] + "requirements": ["python-otbr-api==2.7.0"] } diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 2b4a0367bf766b..51efb52e55db4d 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -41,6 +41,7 @@ PLATFORMS, UPDATE_INTERVAL, UPDATE_INTERVAL_ALL_ASSUMED_STATE, + UPDATE_INTERVAL_LOCAL, ) from .coordinator import OverkizDataUpdateCoordinator @@ -116,13 +117,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) if coordinator.is_stateless: LOGGER.debug( - ( - "All devices have an assumed state. Update interval has been reduced" - " to: %s" - ), + "All devices have an assumed state. Update interval has been reduced to: %s", UPDATE_INTERVAL_ALL_ASSUMED_STATE, ) - coordinator.update_interval = UPDATE_INTERVAL_ALL_ASSUMED_STATE + coordinator.set_update_interval(UPDATE_INTERVAL_ALL_ASSUMED_STATE) + + if api_type == APIType.LOCAL: + LOGGER.debug( + "Devices connect via Local API. Update interval has been reduced to: %s", + UPDATE_INTERVAL_LOCAL, + ) + coordinator.set_update_interval(UPDATE_INTERVAL_LOCAL) platforms: defaultdict[Platform, list[Device]] = defaultdict(list) diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 1398bb7c25ad06..3276a1979cc9d9 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -25,6 +25,7 @@ from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone +from .evo_home_controller import EvoHomeController from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .hitachi_air_to_water_heating_zone import HitachiAirToWaterHeatingZone @@ -53,6 +54,7 @@ class Controllable(StrEnum): UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone, + UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py new file mode 100644 index 00000000000000..272acbb13b990b --- /dev/null +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -0,0 +1,101 @@ +"""Support for EvoHomeController.""" + +from datetime import timedelta + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +import homeassistant.util.dt as dt_util + +from ..entity import OverkizDataUpdateCoordinator, OverkizEntity + +PRESET_DAY_OFF = "day-off" +PRESET_HOLIDAYS = "holidays" + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.OFF: HVACMode.OFF, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.DAY_OFF: PRESET_DAY_OFF, + OverkizCommandParam.HOLIDAYS: PRESET_HOLIDAYS, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + + +class EvoHomeController(OverkizEntity, ClimateEntity): + """Representation of EvoHomeController device.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "EvoHome" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE): + operating_mode = state.value_as_str + + if operating_mode in OVERKIZ_TO_HVAC_MODES: + return OVERKIZ_TO_HVAC_MODES[operating_mode] + + if operating_mode in OVERKIZ_TO_PRESET_MODES: + return HVACMode.OFF + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE] + ) and state.value_as_str in OVERKIZ_TO_PRESET_MODES: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_DAY_OFF: + today_end_of_day = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + time_interval = today_end_of_day + + if preset_mode == PRESET_HOLIDAYS: + one_week_from_now = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=7) + time_interval = one_week_from_now + + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, + PRESET_MODES_TO_OVERKIZ[preset_mode], + time_interval.strftime("%Y/%m/%d %H:%M"), + ) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index 8410e50873d767..c5465128bba430 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -119,5 +119,5 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) ) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 9a94c30d95d32f..af955e5fb95f46 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -23,7 +23,6 @@ from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -34,6 +33,8 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER @@ -273,7 +274,7 @@ async def async_step_local( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" hostname = discovery_info.hostname @@ -284,7 +285,7 @@ async def async_step_dhcp( return await self._process_discovery(gateway_id) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle ZeroConf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 1a89fecf9c0a1f..7f5f4ad85bd40b 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -44,6 +44,7 @@ DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443" UPDATE_INTERVAL: Final = timedelta(seconds=30) +UPDATE_INTERVAL_LOCAL: Final = timedelta(seconds=5) UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) PLATFORMS: list[Platform] = [ @@ -101,6 +102,7 @@ UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 17068d26b7cc2a..484ef138cf7ec2 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -26,7 +26,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.decorator import Registry -from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER EVENT_HANDLERS: Registry[ str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]] @@ -36,6 +36,8 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Class to manage fetching data from Overkiz platform.""" + _default_update_interval: timedelta + def __init__( self, hass: HomeAssistant, @@ -45,7 +47,7 @@ def __init__( client: OverkizClient, devices: list[Device], places: Place | None, - update_interval: timedelta | None = None, + update_interval: timedelta, config_entry_id: str, ) -> None: """Initialize global data updater.""" @@ -59,12 +61,17 @@ def __init__( self.data = {} self.client = client self.devices: dict[str, Device] = {d.device_url: d for d in devices} - self.is_stateless = all( - device.protocol in (Protocol.RTS, Protocol.INTERNAL) for device in devices - ) self.executions: dict[str, dict[str, str]] = {} self.areas = self._places_to_area(places) if places else None self.config_entry_id = config_entry_id + self._default_update_interval = update_interval + + self.is_stateless = all( + device.protocol in (Protocol.RTS, Protocol.INTERNAL) + for device in devices + if device.widget not in IGNORED_OVERKIZ_DEVICES + and device.ui_class not in IGNORED_OVERKIZ_DEVICES + ) async def _async_update_data(self) -> dict[str, Device]: """Fetch Overkiz data via event listener.""" @@ -102,8 +109,9 @@ async def _async_update_data(self) -> dict[str, Device]: if event_handler := EVENT_HANDLERS.get(event.name): await event_handler(self, event) + # Restore the default update interval if no executions are pending if not self.executions: - self.update_interval = UPDATE_INTERVAL + self.update_interval = self._default_update_interval return self.devices @@ -124,6 +132,11 @@ def _places_to_area(self, place: Place) -> dict[str, str]: return areas + def set_update_interval(self, update_interval: timedelta) -> None: + """Set the update interval and store this value.""" + self.update_interval = update_interval + self._default_update_interval = update_interval + @EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE) async def on_device_available( diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 02829eaf1a378c..220c6fe7cb224d 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol -from pyoverkiz.exceptions import OverkizException +from pyoverkiz.exceptions import BaseOverkizException from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.types import StateType as OverkizStateType @@ -105,7 +105,7 @@ async def async_execute_command( "Home Assistant", ) # Catch Overkiz exceptions to support `continue_on_error` functionality - except OverkizException as exception: + except BaseOverkizException as exception: raise HomeAssistantError(exception) from exception # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 84d25b01d245ea..81a9ab41d2d982 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -534,8 +534,7 @@ def native_value(self) -> StateType: # This is probably incorrect and should be fixed in a follow up PR. # To ensure measurement sensors do not get an `unknown` state on # a falsy value (e.g. 0 or 0.0) we also check the state_class. - or self.state_class != SensorStateClass.MEASUREMENT - and not state.value + or (self.state_class != SensorStateClass.MEASUREMENT and not state.value) ): return None diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py index abd3f40adc2f32..f5a9e3d4a7e776 100644 --- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py +++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py @@ -64,10 +64,8 @@ def __init__( for param, mode in OVERKIZ_TO_OPERATION_MODE.items(): # Filter only for mode allowed by this device # or allow all if no mode definition found - if ( - not state_mode_definition - or state_mode_definition.values - and param in state_mode_definition.values + if not state_mode_definition or ( + state_mode_definition.values and param in state_mode_definition.values ): self.operation_mode_to_overkiz[mode] = param self._attr_operation_list.append(param) diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index c16b02739edc6d..e4ac712e053682 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from typing import cast from aiohttp.hdrs import METH_POST from aiohttp.web_request import Request from aiohttp.web_response import Response from python_overseerr import OverseerrConnectionError +from homeassistant.components import cloud from homeassistant.components.webhook import ( async_generate_url, async_register, @@ -17,14 +19,16 @@ from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.http import HomeAssistantView from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS +from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .services import setup_services -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] +CONF_CLOUDHOOK_URL = "cloudhook_url" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -63,6 +67,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + class OverseerrWebhookManager: """Overseerr webhook manager.""" @@ -85,6 +101,8 @@ def webhook_urls(self) -> list[str]: for url in urls: if url not in res: res.append(url) + if CONF_CLOUDHOOK_URL in self.entry.data: + res.append(self.entry.data[CONF_CLOUDHOOK_URL]) return res async def register_webhook(self) -> None: @@ -100,16 +118,18 @@ async def register_webhook(self) -> None: if not await self.check_need_change(): return for url in self.webhook_urls: - if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): - LOGGER.debug("Setting Overseerr webhook to %s", url) - await self.client.set_webhook_notification_config( - enabled=True, - types=REGISTERED_NOTIFICATIONS, - webhook_url=url, - json_payload=JSON_PAYLOAD, - ) + if await self.test_and_set_webhook(url): + return + LOGGER.info("Failed to register Overseerr webhook") + if ( + cloud.async_active_subscription(self.hass) + and CONF_CLOUDHOOK_URL not in self.entry.data + ): + LOGGER.info("Trying to register a cloudhook URL") + url = await _async_cloudhook_generate_url(self.hass, self.entry) + if await self.test_and_set_webhook(url): return - LOGGER.error("Failed to set Overseerr webhook") + LOGGER.error("Failed to register Overseerr cloudhook") async def check_need_change(self) -> bool: """Check if webhook needs to be changed.""" @@ -121,6 +141,19 @@ async def check_need_change(self) -> bool: or current_config.types != REGISTERED_NOTIFICATIONS ) + async def test_and_set_webhook(self, url: str) -> bool: + """Test and set webhook.""" + if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): + LOGGER.debug("Setting Overseerr webhook to %s", url) + await self.client.set_webhook_notification_config( + enabled=True, + types=REGISTERED_NOTIFICATIONS, + webhook_url=url, + json_payload=JSON_PAYLOAD, + ) + return True + return False + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> Response: @@ -129,8 +162,22 @@ async def handle_webhook( LOGGER.debug("Received webhook payload: %s", data) if data["notification_type"].startswith("MEDIA"): await self.entry.runtime_data.async_refresh() + async_dispatcher_send(hass, EVENT_KEY, data) return HomeAssistantView.json({"message": "ok"}) async def unregister_webhook(self) -> None: """Unregister webhook.""" async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + + +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_id = entry.data[CONF_WEBHOOK_ID] + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return cast(str, entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 48f5436c336c90..5c33ca3fcecb86 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -14,6 +14,8 @@ ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" +EVENT_KEY = f"{DOMAIN}_event" + REGISTERED_NOTIFICATIONS = ( NotificationType.REQUEST_PENDING_APPROVAL | NotificationType.REQUEST_APPROVED @@ -23,28 +25,24 @@ | NotificationType.REQUEST_AUTOMATICALLY_APPROVED ) JSON_PAYLOAD = ( - '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"' - '{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa' - 'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"' - ':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\' - '":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu' - 's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":' - '\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}' - '\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ' - 'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting' - 's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB' - 'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId' - '}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty' - 'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",' - '\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern' - 'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep' - 'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported' - 'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":' - '\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c' - 'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":' - '\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented' - 'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}' - '\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di' - 'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented' - 'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"' + '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' + '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' + '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' + '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' + 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' + 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' + 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' + 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' + 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' + 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' + 'By_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_ema' + 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' + 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' + 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' + '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' ) diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py new file mode 100644 index 00000000000000..b1b2efd6ec5239 --- /dev/null +++ b/homeassistant/components/overseerr/event.py @@ -0,0 +1,99 @@ +"""Support for Overseerr events.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EVENT_KEY +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrEventEntityDescription(EventEntityDescription): + """Describes Overseerr config event entity.""" + + nullable_fields: list[str] + + +EVENTS: tuple[OverseerrEventEntityDescription, ...] = ( + OverseerrEventEntityDescription( + key="media", + translation_key="last_media_event", + event_types=[ + "pending", + "approved", + "available", + "failed", + "declined", + "auto_approved", + ], + nullable_fields=["comment", "issue"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + async_add_entities( + OverseerrEvent(coordinator, description) for description in EVENTS + ) + + +class OverseerrEvent(OverseerrEntity, EventEntity): + """Defines a Overseerr event entity.""" + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrEventEntityDescription, + ) -> None: + """Initialize Overseerr event entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update) + ) + + async def _handle_update(self, event: dict[str, Any]) -> None: + """Handle incoming event.""" + event_type = event["notification_type"].lower() + if event_type.split("_")[0] == self.entity_description.key: + self._trigger_event(event_type[6:], event) + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + if super().available != self._attr_available: + self._attr_available = super().available + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available + + +def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: + """Parse event.""" + event.pop("notification_type") + for field in nullable_fields: + event.pop(field) + return event diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index ddcf9ccce5e936..26dfd6d73e3d70 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -1,6 +1,7 @@ { "domain": "overseerr", "name": "Overseerr", + "after_dependencies": ["cloud"], "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["http", "webhook"], diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 338c9d91a384a5..c68963247ee77e 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -21,6 +21,19 @@ } }, "entity": { + "event": { + "last_media_event": { + "name": "Last media event", + "state": { + "pending": "Pending", + "approved": "Approved", + "available": "Available", + "failed": "Failed", + "declined": "Declined", + "auto_approved": "Auto-approved" + } + } + }, "sensor": { "total_requests": { "name": "Total requests" diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index f20b3d11261f04..dbf1baa0c28718 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,7 +7,12 @@ from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py new file mode 100644 index 00000000000000..cd4765576edc6b --- /dev/null +++ b/homeassistant/components/palazzetti/button.py @@ -0,0 +1,52 @@ +"""Support for Palazzetti buttons.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PalazzettiConfigEntry +from .const import DOMAIN +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti button platform.""" + + coordinator = config_entry.runtime_data + if coordinator.client.has_fan_silent: + async_add_entities([PalazzettiSilentButtonEntity(coordinator)]) + + +class PalazzettiSilentButtonEntity(PalazzettiEntity, ButtonEntity): + """Representation of a Palazzetti Silent button.""" + + _attr_translation_key = "silent" + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize a Palazzetti Silent button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-silent" + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.set_fan_silent() + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 356f3a7306fcaa..0722b97e4b7ab5 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PalazzettiConfigEntry -from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT +from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES from .coordinator import PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -57,8 +57,6 @@ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None: self._attr_fan_modes = list( map(str, range(client.fan_speed_min, client.fan_speed_max + 1)) ) - if client.has_fan_silent: - self._attr_fan_modes.insert(0, FAN_SILENT) if client.has_fan_high: self._attr_fan_modes.append(FAN_HIGH) if client.has_fan_auto: @@ -124,15 +122,13 @@ async def async_set_temperature(self, **kwargs: Any) -> None: @property def fan_mode(self) -> str | None: """Return the fan mode.""" - api_state = self.coordinator.client.fan_speed + api_state = self.coordinator.client.current_fan_speed() return FAN_MODES[api_state] async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" try: - if fan_mode == FAN_SILENT: - await self.coordinator.client.set_fan_silent() - elif fan_mode == FAN_HIGH: + if fan_mode == FAN_HIGH: await self.coordinator.client.set_fan_high() elif fan_mode == FAN_AUTO: await self.coordinator.client.set_fan_auto() diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index fe892b6624dd72..91762216ff56df 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,10 +6,10 @@ from pypalazzetti.exceptions import CommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER @@ -53,7 +53,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py index b2e27b2a6fd239..1b68cf99f9d3b7 100644 --- a/homeassistant/components/palazzetti/const.py +++ b/homeassistant/components/palazzetti/const.py @@ -18,7 +18,7 @@ FAN_SILENT: Final = "silent" FAN_HIGH: Final = "high" FAN_AUTO: Final = "auto" -FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] +FAN_MODES: Final = ["0", "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] STATUS_TO_HA: Final[dict[StateType, str]] = { 0: "off", diff --git a/homeassistant/components/palazzetti/icons.json b/homeassistant/components/palazzetti/icons.json new file mode 100644 index 00000000000000..c20a957261807d --- /dev/null +++ b/homeassistant/components/palazzetti/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "silent": { + "default": "mdi:volume-mute" + } + } + } +} diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 70e585071596d3..41e8e0fb4de99f 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.15"] + "requirements": ["pypalazzetti==0.1.19"] } diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index 06114bfef54768..2b303f71fd67c4 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.core import HomeAssistant @@ -21,7 +22,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Palazzetti number platform.""" - async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)]) + + entities: list[PalazzettiEntity] = [ + PalazzettiCombustionPowerEntity(config_entry.runtime_data) + ] + + if config_entry.runtime_data.client.has_fan(FanType.LEFT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT)) + + if config_entry.runtime_data.client.has_fan(FanType.RIGHT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT)) + + async_add_entities(entities) class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): @@ -64,3 +76,49 @@ async def async_set_native_value(self, value: float) -> None: ) from err await self.coordinator.async_request_refresh() + + +class PalazzettiFanEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_device_class = NumberDeviceClass.WIND_SPEED + _attr_native_step = 1 + + def __init__( + self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self.fan = fan + + self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed" + self._attr_native_min_value = coordinator.client.min_fan_speed(fan) + self._attr_native_max_value = coordinator.client.max_fan_speed(fan) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed" + ) + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.current_fan_speed(self.fan) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_fan_speed(int(value), self.fan) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "name": str.lower(self.fan.name), + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index 493b2595117c68..ff8461ad1938ac 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -15,8 +15,8 @@ rules: comment: | This integration does not register actions. docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -35,7 +35,7 @@ rules: status: exempt comment: | This integration does not have configuration. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -51,12 +51,12 @@ rules: discovery-update-info: done discovery: done docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -67,9 +67,7 @@ rules: entity-translations: done exception-translations: done icon-translations: - status: exempt - comment: | - This integration does not have custom icons. + status: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index ad7bc498bd1cff..501ee777fe92d1 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -27,6 +27,9 @@ "invalid_fan_mode": { "message": "Fan mode {value} is invalid." }, + "invalid_fan_speed": { + "message": "Fan {name} speed {value} is invalid." + }, "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, @@ -38,6 +41,11 @@ } }, "entity": { + "button": { + "silent": { + "name": "Silent" + } + }, "climate": { "palazzetti": { "state_attributes": { @@ -54,6 +62,12 @@ "number": { "combustion_power": { "name": "Combustion power" + }, + "fan_left_speed": { + "name": "Left fan speed" + }, + "fan_right_speed": { + "name": "Right fan speed" } }, "sensor": { diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index 24248355f722c5..b9b42cd6ca52f0 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -9,7 +9,6 @@ from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -18,6 +17,7 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -27,7 +27,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _discovery_info: zeroconf.ZeroconfServiceInfo + _discovery_info: ZeroconfServiceInfo async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -128,7 +128,7 @@ async def async_step_reconfigure( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Peblar device.""" if not (sn := discovery_info.properties.get("sn")): diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index b793f4b33aecc2..856e07bb2ee1e2 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -280,7 +280,7 @@ async def _process_create_data(self, data: dict) -> dict: return data @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return info[CONF_NAME] diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 9548029209b0f1..4c8281f21dee51 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -67,8 +67,8 @@ async def validate_input(hass: HomeAssistant, data): # Return the validation result address = ( - f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}' - f'{user_data["address"]["house_number_ext"]}' + f"{user_data['address']['street']} {user_data['address']['house_number']}" + f"{user_data['address']['house_number_ext']}" ) return auth_token, { "title": address, diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index c367d5ec548049..b3979580990d72 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -79,7 +79,10 @@ def _get_address(self): """Get the address that identifies the Picnic service.""" if self._user_address is None: address = self.picnic_api_client.get_user()["address"] - self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}' + self._user_address = ( + f"{address['street']} " + f"{address['house_number']}{address['house_number_ext']}" + ) return self._user_address diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 670ccffaea70c0..02072b6cb4301f 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index eab1d086d4cac6..7f9c2545032597 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -203,7 +203,7 @@ def _update_plexdirect_hostname(): config_entry_update_needed = True else: # pylint: disable-next=raise-missing-from - raise Unauthorized( # noqa: TRY200 + raise Unauthorized( # noqa: B904 "New certificate cannot be validated" " with provided token" ) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 6114dd39a6de77..a94000934eb901 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -16,7 +16,6 @@ ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_CONFIGURATION_URL, @@ -29,6 +28,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_PORT, @@ -105,7 +105,7 @@ async def verify_connection( errors[CONF_BASE] = "response_error" except UnsupportedDeviceError: errors[CONF_BASE] = "unsupported" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Unknown exception while verifying connection with your Plugwise Smile" ) diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index f7ec5ab67161ac..a4a26759b69b1e 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -7,6 +7,7 @@ Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError, + PowerfoxNoDataError, Poweropti, ) @@ -45,5 +46,5 @@ async def _async_update_data(self) -> Poweropti: return await self.client.device(device_id=self.device.id) except PowerfoxAuthenticationError as err: raise ConfigEntryAuthFailed(err) from err - except PowerfoxConnectionError as err: + except (PowerfoxConnectionError, PowerfoxNoDataError) as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py index 8f6b847fca08c9..4c6b0f8c6ebfbe 100644 --- a/homeassistant/components/powerfox/diagnostics.py +++ b/homeassistant/components/powerfox/diagnostics.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Any -from powerfox import PowerMeter, WaterMeter +from powerfox import HeatMeter, PowerMeter, WaterMeter from homeassistant.core import HomeAssistant @@ -52,6 +52,22 @@ async def async_get_config_entry_diagnostics( if isinstance(coordinator.data, WaterMeter) else {} ), + **( + { + "heat_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "total_energy": coordinator.data.total_energy, + "delta_energy": coordinator.data.delta_energy, + "total_volume": coordinator.data.total_volume, + "delta_volume": coordinator.data.delta_volume, + } + } + if isinstance(coordinator.data, HeatMeter) + else {} + ), } for coordinator in powerfox_data ], diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index 7083ffe8de7761..bb72d73b5a8c0d 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.0.0"], + "requirements": ["powerfox==1.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py index 7771f96dd81abf..6505139fcd9c4d 100644 --- a/homeassistant/components/powerfox/sensor.py +++ b/homeassistant/components/powerfox/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass -from powerfox import Device, PowerMeter, WaterMeter +from powerfox import Device, HeatMeter, PowerMeter, WaterMeter from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,7 +23,7 @@ @dataclass(frozen=True, kw_only=True) -class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter)]( +class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)]( SensorEntityDescription ): """Describes Poweropti sensor entity.""" @@ -93,6 +93,40 @@ class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter)]( ), ) +SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = ( + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_total_energy", + translation_key="heat_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.total_energy, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_delta_energy", + translation_key="heat_delta_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda meter: meter.delta_energy, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_total_volume", + translation_key="heat_total_volume", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.total_volume, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_delta_volume", + translation_key="heat_delta_volume", + suggested_display_precision=2, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda meter: meter.delta_volume, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -121,6 +155,15 @@ async def async_setup_entry( ) for description in SENSORS_WATER ) + if isinstance(coordinator.data, HeatMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_HEAT + ) async_add_entities(entities) diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json index 4a7c8e8fa4d222..cb068a212c2e8b 100644 --- a/homeassistant/components/powerfox/strings.json +++ b/homeassistant/components/powerfox/strings.json @@ -64,6 +64,18 @@ }, "warm_water": { "name": "Warm water" + }, + "heat_total_energy": { + "name": "Total energy" + }, + "heat_delta_energy": { + "name": "Delta energy" + }, + "heat_total_volume": { + "name": "Total volume" + }, + "heat_delta_volume": { + "name": "Delta volume" } } } diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 0c39392ca19b74..396ba31b4ee874 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -17,7 +17,6 @@ ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -28,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful @@ -116,7 +116,7 @@ async def _async_powerwall_is_offline(self, entry: ConfigEntry) -> bool: ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index c7311e8691bc0d..90340bc70faaca 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -20,8 +20,7 @@ def _parse_irk(irk: str) -> bytes | None: - if irk.startswith("irk:"): - irk = irk[4:] + irk = irk.removeprefix("irk:") if irk.endswith("="): try: diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 6759cdda0f067c..2ab736b02d377f 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0"] + "requirements": ["bluetooth-data-tools==1.22.0"] } diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 1bf23befbdb3e2..4d090f4d0c1897 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f'{entry.data["host"]}:{entry.data["port"]}' + f"{entry.data['host']}:{entry.data['port']}" ) # Check board validation again to load new values to API. diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2202678da9b5d5..2e5ea221dca3bf 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -19,7 +19,7 @@ async def validate_input(hass: HomeAssistant, data): """Validate the user host input.""" - api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}') + api_instance = ProgettiHWSWAPI(f"{data['host']}:{data['port']}") is_valid = await api_instance.check_board() if not is_valid: diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py index a2bbb671ff7eb4..0dcb1a9ab13203 100644 --- a/homeassistant/components/pure_energie/config_flow.py +++ b/homeassistant/components/pure_energie/config_flow.py @@ -7,11 +7,11 @@ from gridnet import Device, GridNet, GridNetConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -58,7 +58,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_host = discovery_info.host diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index b082e088ba24f7..006093f35453f3 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -6,7 +6,7 @@ "data": { "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "distance": "Search Radius" + "distance": "Search radius" }, "data_description": { "latitude": "The latitude around which to search for sensors", @@ -53,7 +53,7 @@ "options": { "step": { "add_sensor": { - "title": "Add Sensor", + "title": "Add sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { "latitude": "[%key:common::config_flow::data::latitude%]", @@ -67,7 +67,7 @@ } }, "choose_sensor": { - "title": "Choose Sensor to Add", + "title": "Choose sensor to add", "description": "[%key:component::purpleair::config::step::choose_sensor::description%]", "data": { "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]" @@ -84,9 +84,9 @@ } }, "remove_sensor": { - "title": "Remove Sensor", + "title": "Remove sensor", "data": { - "sensor_device_id": "Sensor Name" + "sensor_device_id": "Sensor name" }, "data_description": { "sensor_device_id": "The sensor to remove" diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8b85dfa29a47fc..f07db5096304ff 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo """Set up pyLoad from a config entry.""" url = ( - f"{"https" if entry.data[CONF_SSL] else "http"}://" + f"{'https' if entry.data[CONF_SSL] else 'http'}://" f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" ) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index c8d08f997f9458..5df11711d6f064 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -83,7 +83,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non ) url = ( - f"{"https" if user_input[CONF_SSL] else "http"}://" + f"{'https' if user_input[CONF_SSL] else 'http'}://" f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" ) pyload = PyLoadAPI( diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index af7732780293d9..dbd1a5dce4b37f 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -1,6 +1,6 @@ """Component to allow running Python scripts.""" -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence import datetime import glob import logging @@ -180,7 +180,7 @@ def guarded_import( # Allow import of _strptime needed by datetime.datetime.strptime if name == "_strptime": return __import__(name, globals, locals, fromlist, level) - raise ScriptError(f"Not allowed to import {name}") + raise ImportError(f"Not allowed to import {name}") def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: @@ -197,7 +197,12 @@ def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: @bind_hass -def execute_script(hass, name, data=None, return_response=False): +def execute_script( + hass: HomeAssistant, + name: str, + data: dict[str, Any] | None = None, + return_response: bool = False, +) -> dict | None: """Execute a script.""" filename = f"{name}.py" raise_if_invalid_filename(filename) @@ -207,7 +212,13 @@ def execute_script(hass, name, data=None, return_response=False): @bind_hass -def execute(hass, filename, source, data=None, return_response=False): +def execute( + hass: HomeAssistant, + filename: str, + source: Any, + data: dict[str, Any] | None = None, + return_response: bool = False, +) -> dict | None: """Execute Python source.""" compiled = compile_restricted_exec(source, filename=filename) @@ -223,25 +234,18 @@ def execute(hass, filename, source, data=None, return_response=False): "Warning loading script %s: %s", filename, ", ".join(compiled.warnings) ) - def protected_getattr(obj, name, default=None): + def protected_getattr(obj: object, name: str, default: Any = None) -> Any: """Restricted method to get attributes.""" if name.startswith("async_"): raise ScriptError("Not allowed to access async methods") if ( - obj is hass - and name not in ALLOWED_HASS - or obj is hass.bus - and name not in ALLOWED_EVENTBUS - or obj is hass.states - and name not in ALLOWED_STATEMACHINE - or obj is hass.services - and name not in ALLOWED_SERVICEREGISTRY - or obj is dt_util - and name not in ALLOWED_DT_UTIL - or obj is datetime - and name not in ALLOWED_DATETIME - or isinstance(obj, TimeWrapper) - and name not in ALLOWED_TIME + (obj is hass and name not in ALLOWED_HASS) + or (obj is hass.bus and name not in ALLOWED_EVENTBUS) + or (obj is hass.states and name not in ALLOWED_STATEMACHINE) + or (obj is hass.services and name not in ALLOWED_SERVICEREGISTRY) + or (obj is dt_util and name not in ALLOWED_DT_UTIL) + or (obj is datetime and name not in ALLOWED_DATETIME) + or (isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME) ): raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") @@ -316,10 +320,10 @@ def protected_getattr(obj, name, default=None): class StubPrinter: """Class to handle printing inside scripts.""" - def __init__(self, _getattr_): + def __init__(self, _getattr_: Callable) -> None: """Initialize our printer.""" - def _call_print(self, *objects, **kwargs): + def _call_print(self, *objects: object, **kwargs: Any) -> None: """Print text.""" _LOGGER.warning("Don't use print() inside scripts. Use logger.info() instead") @@ -330,7 +334,7 @@ class TimeWrapper: # Class variable, only going to warn once per Home Assistant run warned = False - def sleep(self, *args, **kwargs): + def sleep(self, *args: Any, **kwargs: Any) -> None: """Sleep method that warns once.""" if not TimeWrapper.warned: TimeWrapper.warned = True @@ -340,12 +344,12 @@ def sleep(self, *args, **kwargs): time.sleep(*args, **kwargs) - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: """Fetch an attribute from Time module.""" attribute = getattr(time, attr) if callable(attribute): - def wrapper(*args, **kw): + def wrapper(*args: Any, **kw: Any) -> Any: """Wrap to return callable method if callable.""" return attribute(*args, **kw) diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py new file mode 100644 index 00000000000000..da9dcfe69be258 --- /dev/null +++ b/homeassistant/components/qbus/__init__.py @@ -0,0 +1,87 @@ +"""The Qbus integration.""" + +import logging + +from homeassistant.components.mqtt import async_wait_for_mqtt_client +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import ( + QBUS_KEY, + QbusConfigCoordinator, + QbusConfigEntry, + QbusControllerCoordinator, +) + +_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 Qbus integration. + + We set up a single coordinator for managing Qbus config updates. The + config update contains the configuration for all controllers (and + config entries). This avoids having each device requesting and managing + the config on its own. + """ + _LOGGER.debug("Loading integration") + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + return False + + config_coordinator = QbusConfigCoordinator.get_or_create(hass) + await config_coordinator.async_subscribe_to_config() + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Set up Qbus from a config entry.""" + _LOGGER.debug("%s - Loading entry", entry.unique_id) + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + coordinator = QbusControllerCoordinator(hass, entry) + entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Get current config + config = await QbusConfigCoordinator.get_or_create( + hass + ).async_get_or_request_config() + + # Update the controller config + if config: + await coordinator.async_update_controller_config(config) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("%s - Unloading entry", entry.unique_id) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.shutdown() + cleanup(hass, entry) + + return unload_ok + + +def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Shutdown if no more entries are loaded.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + count = len(entries) + + # During unloading of the entry, it is not marked as unloaded yet. So + # count can be 1 if it is the last one. + if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)): + config_coordinator.shutdown() diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py new file mode 100644 index 00000000000000..2f08c5b47e2a4d --- /dev/null +++ b/homeassistant/components/qbus/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Qbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from qbusmqttapi.discovery import QbusMqttDevice +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import client as mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ID +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import QbusConfigCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class QbusFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle Qbus config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._gateway_topic = self._topic_factory.get_gateway_state_topic() + self._config_topic = self._topic_factory.get_config_topic() + self._device_topic = self._topic_factory.get_device_state_topic("+") + + self._device: QbusMqttDevice | None = None + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + _LOGGER.debug("Running mqtt discovery for topic %s", discovery_info.topic) + + # Abort if the payload is empty + if not discovery_info.payload: + _LOGGER.debug("Payload empty") + return self.async_abort(reason="invalid_discovery_info") + + match discovery_info.subscribed_topic: + case self._gateway_topic: + return await self._async_handle_gateway_topic(discovery_info) + + case self._config_topic: + return await self._async_handle_config_topic(discovery_info) + + case self._device_topic: + return await self._async_handle_device_topic(discovery_info) + + return self.async_abort(reason="invalid_discovery_info") + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._device is not None + + if user_input is not None: + return self.async_create_entry( + title=f"Controller {self._device.serial_number}", + data={ + CONF_SERIAL_NUMBER: self._device.serial_number, + CONF_ID: self._device.id, + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + CONF_SERIAL_NUMBER: self._device.serial_number, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") + + async def _async_handle_gateway_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling gateway state") + gateway_state = self._message_factory.parse_gateway_state( + discovery_info.payload + ) + + if gateway_state is not None and gateway_state.online is True: + _LOGGER.debug("Requesting config") + await mqtt.async_publish( + self.hass, self._topic_factory.get_get_config_topic(), b"" + ) + + # Abort to wait for config topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_config_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling config topic") + qbus_config = self._message_factory.parse_discovery(discovery_info.payload) + + if qbus_config is not None: + QbusConfigCoordinator.get_or_create(self.hass).store_config(qbus_config) + + _LOGGER.debug("Requesting device states") + device_ids = [x.id for x in qbus_config.devices] + request = self._message_factory.create_state_request(device_ids) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + # Abort to wait for device topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_device_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Discovering device") + qbus_config = await QbusConfigCoordinator.get_or_create( + self.hass + ).async_get_or_request_config() + + if qbus_config is None: + _LOGGER.error("Qbus config not ready") + return self.async_abort(reason="invalid_discovery_info") + + device_id = discovery_info.topic.split("/")[2] + self._device = qbus_config.get_device_by_id(device_id) + + if self._device is None: + _LOGGER.warning("Device with id '%s' not found in config", device_id) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(self._device.serial_number) + + # Do not use error message "already_configured" (which is the + # default), as this will result in unsubscribing from the triggered + # mqtt topic. The topic subscribed to has a wildcard to allow + # discovery of multiple devices. Unsubscribing would result in + # not discovering new or unconfigured devices. + self._abort_if_unique_id_configured(error="device_already_configured") + + self.context.update( + { + "title_placeholders": { + CONF_SERIAL_NUMBER: self._device.serial_number, + } + } + ) + + return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py new file mode 100644 index 00000000000000..ddfb8963cb76d4 --- /dev/null +++ b/homeassistant/components/qbus/const.py @@ -0,0 +1,12 @@ +"""Constants for the Qbus integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "qbus" +PLATFORMS: list[Platform] = [Platform.SWITCH] + +CONF_SERIAL_NUMBER: Final = "serial" + +MANUFACTURER: Final = "Qbus" diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py new file mode 100644 index 00000000000000..dd57a98787b440 --- /dev/null +++ b/homeassistant/components/qbus/coordinator.py @@ -0,0 +1,279 @@ +"""Qbus coordinator.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import cast + +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_wait_for_mqtt_client, + client as mqtt, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.hass_dict import HassKey + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] +QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) + + +class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): + """Qbus data coordinator.""" + + _STATE_REQUEST_DELAY = 3 + + def __init__(self, hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Initialize Qbus coordinator.""" + + _LOGGER.debug("%s - Initializing coordinator", entry.unique_id) + self.config_entry: QbusConfigEntry + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.unique_id or entry.entry_id, + always_update=False, + ) + + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller: QbusMqttDevice | None = None + + # Clean up when HA stops + self.config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + async def _async_update_data(self) -> list[QbusMqttOutput]: + return self._controller.outputs if self._controller else [] + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus coordinator.""" + _LOGGER.debug( + "%s - Shutting down entry coordinator", self.config_entry.unique_id + ) + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller = None + + async def async_update_controller_config(self, config: QbusDiscovery) -> None: + """Update the controller based on the config.""" + _LOGGER.debug("%s - Updating config", self.config_entry.unique_id) + serial = self.config_entry.data.get(CONF_SERIAL_NUMBER, "") + controller = config.get_device_by_serial(serial) + + if controller is None: + _LOGGER.warning( + "%s - Controller with serial %s not found", + self.config_entry.unique_id, + serial, + ) + return + + self._controller = controller + + self._update_device_info() + await self._async_subscribe_to_controller_state() + await self.async_refresh() + self._request_controller_state() + self._request_entity_states() + + def _update_device_info(self) -> None: + if self._controller is None: + return + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, format_mac(self._controller.mac))}, + manufacturer=MANUFACTURER, + model="CTD3.x", + name=f"CTD {self._controller.serial_number}", + serial_number=self._controller.serial_number, + sw_version=self._controller.version, + ) + + async def _async_subscribe_to_controller_state(self) -> None: + if self._controller is None or self._subscribed_to_controller_state is True: + return + + controller_state_topic = self._topic_factory.get_device_state_topic( + self._controller.id + ) + _LOGGER.debug( + "%s - Subscribing to %s", + self.config_entry.unique_id, + controller_state_topic, + ) + self._subscribed_to_controller_state = True + self.config_entry.async_on_unload( + await mqtt.async_subscribe( + self.hass, + controller_state_topic, + self._controller_state_received, + ) + ) + + async def _controller_state_received(self, msg: ReceiveMessage) -> None: + _LOGGER.debug( + "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic + ) + + if self._controller is None or self._controller_activated: + return + + state = self._message_factory.parse_device_state(msg.payload) + + if state and state.properties and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", self.config_entry.unique_id, state.id + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + def _request_entity_states(self) -> None: + async def request_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting %s entity states", + self.config_entry.unique_id, + len(self._controller.outputs), + ) + + request = self._message_factory.create_state_request( + [item.id for item in self._controller.outputs] + ) + + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller and len(self._controller.outputs) > 0: + async_call_later(self.hass, self._STATE_REQUEST_DELAY, request_state) + + def _request_controller_state(self) -> None: + async def request_controller_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting controller state", self.config_entry.unique_id + ) + request = self._message_factory.create_device_state_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller: + async_call_later( + self.hass, self._STATE_REQUEST_DELAY, request_controller_state + ) + + +class QbusConfigCoordinator: + """Class responsible for Qbus config updates.""" + + _qbus_config: QbusDiscovery | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize config coordinator.""" + + self._hass = hass + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + self._cleanup_callbacks: list[CALLBACK_TYPE] = [] + + self._cleanup_callbacks.append( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + @classmethod + def get_or_create(cls, hass: HomeAssistant) -> QbusConfigCoordinator: + """Get the coordinator and create if necessary.""" + if (coordinator := hass.data.get(QBUS_KEY)) is None: + coordinator = cls(hass) + hass.data[QBUS_KEY] = coordinator + + return coordinator + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus config coordinator.""" + _LOGGER.debug("Shutting down Qbus config coordinator") + while self._cleanup_callbacks: + cleanup_callback = self._cleanup_callbacks.pop() + cleanup_callback() + + async def async_subscribe_to_config(self) -> None: + """Subscribe to config changes.""" + config_topic = self._topic_factory.get_config_topic() + _LOGGER.debug("Subscribing to %s", config_topic) + + self._cleanup_callbacks.append( + await mqtt.async_subscribe(self._hass, config_topic, self._config_received) + ) + + async def async_get_or_request_config(self) -> QbusDiscovery | None: + """Get or request Qbus config.""" + _LOGGER.debug("Requesting Qbus config") + + # Config already available + if self._qbus_config: + _LOGGER.debug("Qbus config already available") + return self._qbus_config + + if not await async_wait_for_mqtt_client(self._hass): + _LOGGER.debug("MQTT client not ready yet") + return None + + # Request config + _LOGGER.debug("Publishing config request") + await mqtt.async_publish( + self._hass, self._topic_factory.get_get_config_topic(), b"" + ) + + return self._qbus_config + + def store_config(self, config: QbusDiscovery) -> None: + "Store the Qbus config." + _LOGGER.debug("Storing config") + + self._qbus_config = config + + async def _config_received(self, msg: ReceiveMessage) -> None: + """Handle the received MQTT message containing the Qbus config.""" + _LOGGER.debug("Receiving Qbus config") + + config = self._message_factory.parse_discovery(msg.payload) + + if config is None: + _LOGGER.debug("Incomplete Qbus config") + return + + self.store_config(config) + + for entry in self._hass.config_entries.async_loaded_entries(DOMAIN): + entry = cast(QbusConfigEntry, entry) + await entry.runtime_data.async_update_controller_config(config) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py new file mode 100644 index 00000000000000..39bcddaaf4ff0a --- /dev/null +++ b/homeassistant/components/qbus/entity.py @@ -0,0 +1,76 @@ +"""Base class for Qbus entities.""" + +from abc import ABC, abstractmethod +import re + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttState + +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER + +_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") + + +def format_ref_id(ref_id: str) -> str | None: + """Format the Qbus ref_id.""" + matches: list[str] = re.findall(_REFID_REGEX, ref_id) + + if len(matches) > 0: + if ref_id := matches[0]: + return ref_id.replace("/", "-") + + return None + + +class QbusEntity(Entity, ABC): + """Representation of a Qbus entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize the Qbus entity.""" + + self._topic_factory = QbusMqttTopicFactory() + self._message_factory = QbusMqttMessageFactory() + + ref_id = format_ref_id(mqtt_output.ref_id) + + self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=(DOMAIN, format_mac(mqtt_output.device.mac)), + ) + + self._mqtt_output = mqtt_output + self._state_topic = self._topic_factory.get_output_state_topic( + mqtt_output.device.id, mqtt_output.id + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove( + await mqtt.async_subscribe( + self.hass, self._state_topic, self._state_received + ) + ) + + @abstractmethod + async def _state_received(self, msg: ReceiveMessage) -> None: + pass + + async def _async_publish_output_state(self, state: QbusMqttState) -> None: + request = self._message_factory.create_set_output_state_request( + self._mqtt_output.device, state + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json new file mode 100644 index 00000000000000..ac76110363f224 --- /dev/null +++ b/homeassistant/components/qbus/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "qbus", + "name": "Qbus", + "codeowners": ["@Qbus-iot", "@thomasddn"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/qbus", + "integration_type": "hub", + "iot_class": "local_push", + "mqtt": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state" + ], + "quality_scale": "bronze", + "requirements": ["qbusmqttapi==1.2.3"] +} diff --git a/homeassistant/components/qbus/quality_scale.yaml b/homeassistant/components/qbus/quality_scale.yaml new file mode 100644 index 00000000000000..7e106ef6b9330f --- /dev/null +++ b/homeassistant/components/qbus/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: | + The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + 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: + status: exempt + comment: | + The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: The integration uses the name of what the user configured in the closed system. + exception-translations: todo + icon-translations: + status: exempt + comment: The integration creates unknown number of entities based on what is in the closed system and does not know what each entity stands for. + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json new file mode 100644 index 00000000000000..b8918497c41895 --- /dev/null +++ b/homeassistant/components/qbus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "Controller {serial}", + "step": { + "discovery_confirm": { + "title": "Add controller", + "description": "Add controller {serial}?" + } + }, + "abort": { + "already_configured": "Controller already configured", + "discovery_in_progress": "Discovery in progress", + "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention." + }, + "error": { + "no_controller": "No controllers were found" + } + } +} diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py new file mode 100644 index 00000000000000..2413b8f152fb74 --- /dev/null +++ b/homeassistant/components/qbus/switch.py @@ -0,0 +1,83 @@ +"""Support for Qbus switch.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttOnOffState, StateType + +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback +) -> None: + """Set up switch entities.""" + coordinator = entry.runtime_data + + added_outputs: list[QbusMqttOutput] = [] + + # Local function that calls add_entities for new entities + def _check_outputs() -> None: + added_output_ids = {k.id for k in added_outputs} + + new_outputs = [ + item + for item in coordinator.data + if item.type == "onoff" and item.id not in added_output_ids + ] + + if new_outputs: + added_outputs.extend(new_outputs) + add_entities([QbusSwitch(output) for output in new_outputs]) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusSwitch(QbusEntity, SwitchEntity): + """Representation of a Qbus switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + mqtt_output: QbusMqttOutput, + ) -> None: + """Initialize switch entity.""" + + super().__init__(mqtt_output) + + self._attr_is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(True) + + await self._async_publish_output_state(state) + self._attr_is_on = True + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(False) + + await self._async_publish_output_state(state) + self._attr_is_on = False + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttOnOffState, msg.payload + ) + + if output is not None: + self._attr_is_on = output.read_value() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 3a10e54ac82fb7..3ccb13e0f649f9 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -9,12 +9,12 @@ from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -73,7 +73,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_url = f"http://{discovery_info.ip}" diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64e560b4f0857d..64b95fb17f6162 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -48,9 +48,9 @@ def __init__(self, sensor): self._decode, self.unit = SENSORS[sensor_type] # this cannot happen because it only happens in bool and this should be redirected to binary_sensor - assert not isinstance( - self.unit, type - ), f"boolean sensor id={sensor['id']} name={sensor['name']}" + assert not isinstance(self.unit, type), ( + f"boolean sensor id={sensor['id']} name={sensor['name']}" + ) @callback def update_packet(self, packet): diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 1bee69219b0e37..f4487a73b58bd3 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -99,7 +100,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac = dr.format_mac(discovery_info.properties["id"]) diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index fac93952b35af0..cc32bd2e56f6dc 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -10,7 +10,6 @@ from requests.exceptions import ConnectTimeout import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_MANUAL_RUN_MINS, @@ -92,13 +95,11 @@ async def async_step_user( ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index e29c4703e0877a..298421d39648ce 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -9,11 +9,11 @@ from radiotherm.validate import RadiothermTstatError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN from .data import RadioThermInitData, async_get_init_data @@ -44,7 +44,7 @@ def __init__(self) -> None: self.discovered_init_data: RadioThermInitData | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Discover via DHCP.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index 72d258dc1d32a9..f8e3dde446ae6f 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -20,6 +20,7 @@ SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -30,7 +31,7 @@ def _format_id(value: str | int) -> str: return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: +def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" return ( f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" @@ -98,9 +99,7 @@ async def async_step_meters( ) return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" device = discovery_info.device dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880008..3a902377c2e977 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 0b40d506566235..6ce95d7e547006 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -9,7 +9,6 @@ from regenmaschine.errors import RainMachineError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, @@ -66,19 +66,19 @@ def async_get_options_flow( return RainMachineOptionsFlowHandler() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_homekit_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" ip_address = discovery_info.host diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index b73744ef0d1b45..f203d6ab69a888 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -17,7 +17,7 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util import dt as dt_util -from ...const import SQLITE_MAX_BIND_VARS +from ...const import DEFAULT_MAX_BIND_VARS from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm from ...util import database_job_retry_wrapper, execute @@ -61,7 +61,7 @@ def _find_duplicates( ) .filter(subquery.c.is_duplicate == 1) .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) original_as_dict = {} @@ -125,10 +125,10 @@ def _delete_duplicates_from_table( if not duplicate_ids: break all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .filter(table.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS])) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows @@ -205,7 +205,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: ) .filter(subquery.c.is_duplicate == 1) .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) statistic_id = None @@ -230,11 +230,11 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: duplicate_ids = _find_statistics_meta_duplicates(session) if not duplicate_ids: break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(StatisticsMeta) .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + StatisticsMeta.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS]) ) .delete(synchronize_session=False) ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 409641e54c94ec..c91845e8436495 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -32,16 +32,6 @@ # The maximum number of rows (events) we purge in one delete statement -# sqlite3 has a limit of 999 until version 3.32.0 -# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc -# We can increase this back to 1000 once most -# have upgraded their sqlite version -SQLITE_MAX_BIND_VARS = 998 - -# The maximum bind vars for sqlite 3.32.0 and above, but -# capped at 4000 to avoid performance issues -SQLITE_MODERN_MAX_BIND_VARS = 4000 - DEFAULT_MAX_BIND_VARS = 4000 DB_WORKER_PREFIX = "DbWorker" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fee72ce273f09f..5a405061a94599 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -52,6 +52,7 @@ from . import migration, statistics from .const import ( DB_WORKER_PREFIX, + DEFAULT_MAX_BIND_VARS, DOMAIN, KEEPALIVE_TIME, LAST_REPORTED_SCHEMA_VERSION, @@ -61,7 +62,6 @@ MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -230,12 +230,9 @@ def __init__( self._dialect_name: SupportedDialect | None = None self.enabled = True - # For safety we default to the lowest value for max_bind_vars - # of all the DB types (SQLITE_MAX_BIND_VARS). - # # We update the value once we connect to the DB # and determine what is actually supported. - self.max_bind_vars = SQLITE_MAX_BIND_VARS + self.max_bind_vars = DEFAULT_MAX_BIND_VARS @property def backlog(self) -> int: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 93ffb12d18cf48..d3b6e52ad116e0 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.36", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6ae1e265901029..c6cdd6d317f7c6 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2073,10 +2073,7 @@ def _wipe_old_string_time_columns( session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( - text( - "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 100000;" - ) + text("UPDATE states set last_updated=NULL, last_changed=NULL LIMIT 100000;") ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -2150,7 +2147,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2181,7 +2178,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2280,7 +2277,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2302,7 +2299,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2755,9 +2752,9 @@ def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_event_type.event_type is not None - ), "event_type should never be None" + assert db_event_type.event_type is not None, ( + "event_type should never be None" + ) event_type_to_id[db_event_type.event_type] = ( db_event_type.event_type_id ) @@ -2833,9 +2830,9 @@ def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: for db_states_metadata in missing_states_metadata: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_states_metadata.entity_id is not None - ), "entity_id should never be None" + assert db_states_metadata.entity_id is not None, ( + "entity_id should never be None" + ) entity_id_to_metadata_id[db_states_metadata.entity_id] = ( db_states_metadata.metadata_id ) diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index a469aa49ab2545..b5e67ff050bcf6 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -24,12 +24,12 @@ class LegacyLazyState(State): """A lazy version of core State after schema 31.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed_ts", - "_last_updated_ts", "_last_reported_ts", - "_context", + "_last_updated_ts", + "_row", "attr_cache", ] diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index fbf73e75025a25..d73c204079d190 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -58,8 +58,8 @@ def __init__( # pylint: disable=super-init-not-called self.attr_cache = attr_cache self.context = EMPTY_CONTEXT - @cached_property # type: ignore[override] - def attributes(self) -> dict[str, Any]: + @cached_property + def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" return decode_attributes_from_source( getattr(self._row, "attributes", None), self.attr_cache diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index fc2a8ccb1ccf7b..30e277d7c0a985 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -47,9 +47,9 @@ def __init__( # pylint: disable=super-init-not-called ) -> None: """Create the pool.""" kw["pool_size"] = POOL_SIZE - assert ( - recorder_and_worker_thread_ids is not None - ), "recorder_and_worker_thread_ids is required" + assert recorder_and_worker_thread_ids is not None, ( + "recorder_and_worker_thread_ids is required" + ) self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids SingletonThreadPool.__init__(self, creator, **kw) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c6783a5cbc2c50..8995f57ef30d4d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -968,12 +968,10 @@ def _reduce_statistics( return result -def reduce_day_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_day_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same day and day start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1017,12 +1015,10 @@ def _reduce_statistics_per_day( ) -def reduce_week_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_week_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same week and week start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1075,12 +1071,10 @@ def _find_month_end_time(timestamp: datetime) -> datetime: ) -def reduce_month_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_month_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same month and month start end.""" _lower_bound: float = 0 _upper_bound: float = 0 diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 2ded6be58d6c05..43c2ecdc14f383 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -16,10 +16,6 @@ "backup_failed_out_of_resources": { "title": "Database backup failed due to lack of resources", "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." - }, - "sqlite_too_old": { - "title": "Update SQLite to {min_version} or later to continue using the recorder", - "description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software." } }, "services": { diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 16feaa198868e3..6923b792b8bfc0 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -40,7 +40,7 @@ def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) and (db_bytes := get_size(session, database_name)) ): - db_stats["estimated_db_size"] = f"{db_bytes/1024/1024:.2f} MiB" + db_stats["estimated_db_size"] = f"{db_bytes / 1024 / 1024:.2f} MiB" return db_stats diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4cf24eb79c549f..a1f8d90953cfd4 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -36,14 +36,7 @@ ) import homeassistant.util.dt as dt_util -from .const import ( - DEFAULT_MAX_BIND_VARS, - DOMAIN, - SQLITE_MAX_BIND_VARS, - SQLITE_MODERN_MAX_BIND_VARS, - SQLITE_URL_PREFIX, - SupportedDialect, -) +from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -95,9 +88,7 @@ def _simple_version(version: str) -> AwesomeVersion: MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") -MIN_VERSION_SQLITE = _simple_version("3.31.0") -UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1") -MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") +MIN_VERSION_SQLITE = _simple_version("3.40.1") # This is the maximum time after the recorder ends the session @@ -376,37 +367,6 @@ def _raise_if_version_unsupported( raise UnsupportedDialect -@callback -def _async_delete_issue_deprecated_version( - hass: HomeAssistant, dialect_name: str -) -> None: - """Delete the issue about upcoming unsupported database version.""" - ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old") - - -@callback -def _async_create_issue_deprecated_version( - hass: HomeAssistant, - server_version: AwesomeVersion, - dialect_name: str, - min_version: AwesomeVersion, -) -> None: - """Warn about upcoming unsupported database version.""" - ir.async_create_issue( - hass, - DOMAIN, - f"{dialect_name}_too_old", - is_fixable=False, - severity=ir.IssueSeverity.CRITICAL, - translation_key=f"{dialect_name}_too_old", - translation_placeholders={ - "server_version": str(server_version), - "min_version": str(min_version), - }, - breaks_in_ha_version="2025.2.0", - ) - - def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -505,7 +465,6 @@ def setup_connection_for_dialect( version: AwesomeVersion | None = None slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: - max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -523,23 +482,6 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) - # No elif here since _raise_if_version_unsupported raises - if version < UPCOMING_MIN_VERSION_SQLITE: - instance.hass.add_job( - _async_create_issue_deprecated_version, - instance.hass, - version or version_string, - dialect_name, - UPCOMING_MIN_VERSION_SQLITE, - ) - else: - instance.hass.add_job( - _async_delete_issue_deprecated_version, instance.hass, dialect_name - ) - - if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: - max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS - # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -558,7 +500,6 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") @@ -599,7 +540,6 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS # PostgreSQL does not support a skip/loose index scan so its # also slow for large distinct queries: # https://wiki.postgresql.org/wiki/Loose_indexscan @@ -626,7 +566,7 @@ def setup_connection_for_dialect( dialect=SupportedDialect(dialect_name), version=version, optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), - max_bind_vars=max_bind_vars, + max_bind_vars=DEFAULT_MAX_BIND_VARS, ) @@ -987,10 +927,7 @@ def _filter_unique_constraint_integrity_error(err: Exception) -> bool: if ignore: _LOGGER.warning( - ( - "Blocked attempt to insert duplicated %s rows, please report" - " at %s" - ), + "Blocked attempt to insert duplicated %s rows, please report at %s", row_type, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", exc_info=err, diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index da499b0584c68c..de6ae8a7f0408a 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -2,7 +2,7 @@ "services": { "create_task": { "name": "Create task", - "description": "Creates (or update) a new task in your Remember The Milk account. If you want to update a task later on, you have to set an \"id\" when creating the task. Note: Updating a tasks does not support the smart syntax.", + "description": "Creates a new task in your Remember The Milk account or updates an existing one. If you want to update a task later on, you have to set an \"ID\" when creating the task. Note: Updating a task does not support the smart syntax.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index a4817fc84e6385..1a599afe4e4487 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.2.8"] + "requirements": ["renault-api==0.2.9"] } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index dd791bbaf1a52e..747e68e8a00650 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -361,7 +361,7 @@ def migrate_entity_ids( if host.api.supported(None, "UID") and not entity.unique_id.startswith( host.unique_id ): - new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index c28e076aab4e77..48be2fc8ca7bba 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -15,7 +15,6 @@ ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -34,6 +33,7 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ( @@ -142,7 +142,7 @@ async def async_step_reconfigure( return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index fbbf50ba20ee2a..412362fc4472f6 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -34,7 +34,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "unique_id_mismatch": "The mac address of the device does not match the previous mac address" + "unique_id_mismatch": "The MAC address of the device does not match the previous MAC address" } }, "options": { @@ -54,7 +54,7 @@ "message": "Reolink {service_name} error: config entry not found or not loaded" }, "service_not_chime": { - "message": "Reolink play_chime error: {device_name} is not a chime" + "message": "Reolink play_chime error: {device_name} is not a Chime" }, "invalid_parameter": { "message": "Invalid input parameter: {err}" @@ -89,6 +89,9 @@ "timeout": { "message": "Timeout waiting on a response: {err}" }, + "unexpected": { + "message": "Unexpected Reolink error: {err}" + }, "firmware_install_error": { "message": "Error trying to update Reolink firmware: {err}" }, @@ -117,10 +120,6 @@ "title": "Reolink firmware update required", "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, - "hdr_switch_deprecated": { - "title": "Reolink HDR switch deprecated", - "description": "The Reolink HDR switch entity is deprecated and will be removed in HA 2025.2.0. It has been replaced by a HDR select entity offering options `on`, `off` and `auto`. To remove this issue, please adjust automations accordingly and disable the HDR switch entity." - }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." @@ -129,7 +128,7 @@ "services": { "ptz_move": { "name": "PTZ move", - "description": "Move the camera with a specific speed.", + "description": "Moves the camera with a specific speed.", "fields": { "speed": { "name": "Speed", @@ -139,11 +138,11 @@ }, "play_chime": { "name": "Play chime", - "description": "Play a ringtone on a chime.", + "description": "Plays a ringtone on a Reolink Chime.", "fields": { "device_id": { "name": "Target chime", - "description": "The chime to play the ringtone on." + "description": "The Reolink Chime to play the ringtone on." }, "ringtone": { "name": "Ringtone", diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index b970d04c257ad1..85c35b5c987cb6 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -267,18 +267,6 @@ class ReolinkChimeSwitchEntityDescription( ), ) -# Can be removed in HA 2025.2.0 -DEPRECATED_HDR = ReolinkSwitchEntityDescription( - key="hdr", - cmd_key="GetIsp", - translation_key="hdr", - entity_category=EntityCategory.CONFIG, - entity_registry_enabled_default=False, - supported=lambda api, ch: api.supported(ch, "HDR"), - value=lambda api, ch: api.HDR_on(ch) is True, - method=lambda api, ch, value: api.set_HDR(ch, value), -) - # Can be removed in HA 2025.4.0 DEPRECATED_NVR_SWITCHES = [ ReolinkNVRSwitchEntityDescription( @@ -367,26 +355,6 @@ async def async_setup_entry( entity_reg = er.async_get(hass) reg_entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) for entity in reg_entities: - # Can be removed in HA 2025.2.0 - if entity.domain == "switch" and entity.unique_id.endswith("_hdr"): - if entity.disabled: - entity_reg.async_remove(entity.entity_id) - continue - - ir.async_create_issue( - hass, - DOMAIN, - "hdr_switch_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="hdr_switch_deprecated", - ) - entities.extend( - ReolinkSwitchEntity(reolink_data, channel, DEPRECATED_HDR) - for channel in reolink_data.host.api.channels - if DEPRECATED_HDR.supported(reolink_data.host.api, channel) - ) - # Can be removed in HA 2025.4.0 if entity.domain == "switch" and entity.unique_id in depricated_dict: if entity.disabled: diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index f52cb08286ceb1..e43391f19fb08e 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, ParamSpec, TypeVar +from typing import Any from reolink_aio.exceptions import ( ApiError, @@ -82,21 +82,18 @@ def get_device_uid_and_ch( ch = int(device_uid[1][5:]) is_chime = True else: - ch = host.api.channel_for_uid(device_uid[1]) + device_uid_part = "_".join(device_uid[1:]) + ch = host.api.channel_for_uid(device_uid_part) return (device_uid, ch, is_chime) -T = TypeVar("T") -P = ParamSpec("P") - - # Decorators -def raise_translated_error( - func: Callable[P, Awaitable[T]], -) -> Callable[P, Coroutine[Any, Any, T]]: +def raise_translated_error[**P, R]( + func: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: """Wrap a reolink-aio function to translate any potential errors.""" - async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> T: + async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> R: """Try a reolink-aio function and translate any potential errors.""" try: return await func(*args, **kwargs) @@ -167,6 +164,10 @@ async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected", + translation_placeholders={"err": str(err)}, + ) from err return decorator_raise_translated_error diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 3b32ebaf74e5be..1a4585bc997680 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -2,9 +2,9 @@ from __future__ import annotations +from base64 import urlsafe_b64decode, urlsafe_b64encode from http import HTTPStatus import logging -from urllib import parse from aiohttp import ClientError, ClientTimeout, web from reolink_aio.enums import VodRequestType @@ -31,7 +31,7 @@ def async_generate_playback_proxy_url( return url_format.format( config_entry_id=config_entry_id, channel=channel, - filename=parse.quote(filename, safe=""), + filename=urlsafe_b64encode(filename.encode("utf-8")).decode("utf-8"), stream_res=stream_res, vod_type=vod_type, ) @@ -66,7 +66,7 @@ async def get( """Get playback proxy video response.""" retry = retry - 1 - filename = parse.unquote(filename) + filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8") ch = int(channel) try: host = get_host(self.hass, config_entry_id) @@ -77,7 +77,7 @@ async def get( try: mime_type, reolink_url = await host.api.get_vod_source( - ch, filename, stream_res, VodRequestType(vod_type) + ch, filename_decoded, stream_res, VodRequestType(vod_type) ) except ReolinkError as err: _LOGGER.warning("Reolink playback proxy error: %s", str(err)) diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8d3fc429ce02ba..8ee09c9ed3d0cb 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -12,11 +12,11 @@ from .models import RepairsFlow __all__ = [ - "ConfirmRepairFlow", "DOMAIN", - "repairs_flow_manager", + "ConfirmRepairFlow", "RepairsFlow", "RepairsFlowManager", + "repairs_flow_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb217..6ce7d88f9f0711 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ async def async_step_set_device_options( except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ async def async_step_set_device_options( vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py index b5752e366bc321..f0cc193023c471 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -46,7 +46,7 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers=_get_identifiers_from_device_tuple(device_id), model=device.type_string, - name=f"{device.type_string} {device.id_string}", + name=f"{device.type_string} {device_id.id_string}", ) self._attr_unique_id = "_".join(x for x in device_id) self._device = device @@ -54,7 +54,7 @@ def __init__( self._device_id = device_id # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to # group events regardless of their group indices. - (self._group_id, _, _) = cast(str, device.id_string).partition(":") + (self._group_id, _, _) = device_id.id_string.partition(":") async def async_added_to_hass(self) -> None: """Restore RFXtrx device state (ON/OFF).""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4f8ae9767e2202..13f3c012af8d2c 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -57,7 +57,7 @@ def _rssi_convert(value: int | None) -> str | None: """Rssi is given as dBm value.""" if value is None: return None - return f"{value*8-120}" + return f"{value * 8 - 120}" @dataclass(frozen=True) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3398..db4efad5bb4552 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -54,7 +54,7 @@ "data": { "off_delay": "Off delay", "off_delay_enabled": "Enable off delay", - "data_bit": "Number of data bits", + "data_bits": "Number of data bits", "command_on": "Data bits value for command on", "command_off": "Data bits value for command off", "venetian_blind_mode": "Venetian blind mode", @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 1464cccb5c4cd6..cd17e71f4f0d79 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -35,8 +35,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter - or isinstance(event.device, rfxtrxmod.RfyDevice) - ) + ) or isinstance(event.device, rfxtrxmod.RfyDevice) async def async_setup_entry( diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a1024186349b07..a23fd8f73de6b2 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,7 +8,6 @@ from ring_doorbell import Auth, AuthenticationError, Requires2FAError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -26,6 +25,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN @@ -78,7 +78,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): hardware_id: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" # Ring has a single config entry per cloud username rather than per device diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index b93a7f35322fb4..d48cc35a4f52de 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, Concatenate, Generic, cast +from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( AuthenticationError, @@ -11,7 +11,6 @@ RingGeneric, RingTimeout, ) -from typing_extensions import TypeVar from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 8fd437e7e1db2b..ac6c66bb6d271c 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -271,11 +271,9 @@ async def async_update(self): if not dest_found: continue - if ( - self._lines - and journey["number"] not in self._lines - or journey["minutes"] < self._time_offset - ): + if (self._lines and journey["number"] not in self._lines) or journey[ + "minutes" + ] < self._time_offset: continue for attr in ("direction", "departure_time", "product", "minutes"): diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index d02dddece42f1a..9ab9226c9a5744 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -9,7 +9,13 @@ import logging from typing import Any -from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials +from roborock import ( + HomeDataRoom, + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockMqttClientA01 @@ -60,12 +66,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="invalid_credentials", ) from err + except RoborockInvalidUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="invalid_user_agreement", + ) from err + except RoborockNoUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_user_agreement", + ) from err except RoborockException as err: raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, translation_key="home_data_fail", ) from err + _LOGGER.debug("Got home data %s", home_data) all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices device_map: dict[str, HomeDataDevice] = { @@ -188,14 +205,6 @@ async def setup_device_v1( coordinator = RoborockDataUpdateCoordinator( hass, device, networking, product_info, mqtt_client, home_data_rooms ) - # Verify we can communicate locally - if we can't, switch to cloud api - await coordinator.verify_api() - coordinator.api.is_available = True - try: - await coordinator.get_maps() - except RoborockException as err: - _LOGGER.warning("Failed to get map data") - _LOGGER.debug(err) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 200614b024e2d0..1a6b67286bb1c6 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -60,7 +60,7 @@ async def async_step_user( if user_input is not None: username = user_input[CONF_USERNAME] await self.async_set_unique_id(username.lower()) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient(username) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index fe592074f710a7..443e50642f2484 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -73,7 +73,27 @@ def __init__( self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} - async def verify_api(self) -> None: + async def _async_setup(self) -> None: + """Set up the coordinator.""" + # Verify we can communicate locally - if we can't, switch to cloud api + await self._verify_api() + self.api.is_available = True + + try: + maps = await self.api.get_multi_maps_list() + except RoborockException as err: + raise UpdateFailed("Failed to get map data: {err}") from err + # Rooms names populated later with calls to `set_current_map_rooms` for each map + self.maps = { + roborock_map.mapFlag: RoborockMapInfo( + flag=roborock_map.mapFlag, + name=roborock_map.name or f"Map {roborock_map.mapFlag}", + rooms={}, + ) + for roborock_map in (maps.map_info if (maps and maps.map_info) else ()) + } + + async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" if isinstance(self.api, RoborockLocalClientV1): try: @@ -96,12 +116,8 @@ async def release(self) -> None: async def _update_device_prop(self) -> None: """Update device properties.""" - device_prop = await self.api.get_prop() - if device_prop: - if self.roborock_device_info.props: - self.roborock_device_info.props.update(device_prop) - else: - self.roborock_device_info.props = device_prop + if (device_prop := await self.api.get_prop()) is not None: + self.roborock_device_info.props.update(device_prop) async def _async_update_data(self) -> DeviceProp: """Update data via library.""" @@ -111,7 +127,7 @@ async def _async_update_data(self) -> DeviceProp: # Set the new map id from the updated device props self._set_current_map() # Get the rooms for that map id. - await self.get_rooms() + await self.set_current_map_rooms() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props @@ -127,27 +143,18 @@ def _set_current_map(self) -> None: self.roborock_device_info.props.status.map_status - 3 ) // 4 - async def get_maps(self) -> None: - """Add a map to the coordinators mapping.""" - maps = await self.api.get_multi_maps_list() - if maps and maps.map_info: - for roborock_map in maps.map_info: - self.maps[roborock_map.mapFlag] = RoborockMapInfo( - flag=roborock_map.mapFlag, name=roborock_map.name, rooms={} - ) - - async def get_rooms(self) -> None: - """Get all of the rooms for the current map.""" + async def set_current_map_rooms(self) -> None: + """Fetch all of the rooms for the current map and set on RoborockMapInfo.""" # The api is only able to access rooms for the currently selected map # So it is important this is only called when you have the map you care # about selected. - if self.current_map in self.maps: - iot_rooms = await self.api.get_room_mapping() - if iot_rooms is not None: - for room in iot_rooms: - self.maps[self.current_map].rooms[room.segment_id] = ( - self._home_data_rooms.get(room.iot_id, "Unknown") - ) + if self.current_map is None or self.current_map not in self.maps: + return + room_mapping = await self.api.get_room_mapping() + self.maps[self.current_map].rooms = { + room.segment_id: self._home_data_rooms.get(room.iot_id, "Unknown") + for room in room_mapping or () + } @cached_property def duid(self) -> str: diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index 15414cfc2d9d86..6a96b04e12e84a 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -61,6 +61,9 @@ "total_cleaning_area": { "default": "mdi:texture-box" }, + "total_cleaning_count": { + "default": "mdi:counter" + }, "vacuum_error": { "default": "mdi:alert-circle" }, diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index ee48656290ff75..8717920b9078f2 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -121,7 +121,10 @@ async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" if self.is_map_valid(): response = await asyncio.gather( - *(self.cloud_api.get_map_v1(), self.coordinator.get_rooms()), + *( + self.cloud_api.get_map_v1(), + self.coordinator.set_current_map_rooms(), + ), return_exceptions=True, ) if not isinstance(response[0], bytes): @@ -174,7 +177,8 @@ async def create_coordinator_maps( await asyncio.sleep(MAP_SLEEP) # Get the map data map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.get_rooms()], return_exceptions=True + *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], + return_exceptions=True, ) # If we fail to get the map, we should set it to empty byte, # still create it, and set it as unavailable. diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 47849ed5cc53ce..e01a03d7720dc7 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -24,6 +24,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant @@ -112,6 +113,13 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: value_fn=lambda data: data.clean_summary.clean_time, entity_category=EntityCategory.DIAGNOSTIC, ), + RoborockSensorDescription( + key="total_cleaning_count", + translation_key="total_cleaning_count", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.clean_summary.clean_count, + entity_category=EntityCategory.DIAGNOSTIC, + ), RoborockSensorDescription( key="status", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index e47be119a0e625..7005344614c04f 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,7 +28,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -228,6 +228,9 @@ "total_cleaning_area": { "name": "Total cleaning area" }, + "total_cleaning_count": { + "name": "Total cleaning count" + }, "vacuum_error": { "name": "Vacuum error", "state": { @@ -422,6 +425,12 @@ }, "update_options_failed": { "message": "Failed to update Roborock options" + }, + "invalid_user_agreement": { + "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement." + }, + "no_user_agreement": { + "message": "You have not valid user agreement. Open your Roborock app and accept the agreement." } }, "services": { diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index bc0092d6953851..2fb016b5467de3 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -9,7 +9,6 @@ from rokuecp import Roku, RokuError import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -19,6 +18,12 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN @@ -117,7 +122,7 @@ async def async_step_user( return self.async_create_entry(title=info["title"], data=user_input) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" @@ -147,12 +152,12 @@ async def async_step_homekit( return await self.async_step_discovery_confirm() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname - name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index bd47585db1b31c..04348bc3bfb421 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -23,7 +23,7 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_device": "This Roku device does not match the existing device id. Please make sure you entered the correct host information." + "wrong_device": "This Roku device does not match the existing device ID. Please make sure you entered the correct host information." } }, "options": { diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index e571ff41c9a8de..6bb5c337b29161 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -5,10 +5,10 @@ import romy import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -84,7 +84,7 @@ async def async_step_password( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d040074246adfa..b7d259e3131ac2 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,7 +11,6 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,8 @@ ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( @@ -95,7 +96,7 @@ def async_get_options_flow( return RoombaOptionsFlowHandler() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" return await self._async_step_discovery( @@ -103,7 +104,7 @@ async def async_step_zeroconf( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" return await self._async_step_discovery( diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 5618a424726b18..edf542b5de2561 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -8,7 +8,6 @@ from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -16,6 +15,7 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -39,7 +39,7 @@ def __init__(self) -> None: self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 62981262e32470..346f4903f6a911 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt import logging from typing import TYPE_CHECKING @@ -57,6 +58,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SEEK ) def __init__( @@ -138,6 +140,21 @@ def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._source.cover_art_url + @property + def media_duration(self) -> int | None: + """Duration of the current media.""" + return self._source.track_time + + @property + def media_position(self) -> int | None: + """Position of the current media.""" + return self._source.play_time + + @property + def media_position_updated_at(self) -> dt.datetime: + """Last time the media position was updated.""" + return self._source.position_last_updated + @property def volume_level(self) -> float: """Volume level of the media player (0..1). @@ -199,3 +216,8 @@ async def async_mute_volume(self, mute: bool) -> None: if mute != self.is_volume_muted: await self._zone.toggle_mute() + + @command + async def async_media_seek(self, position: float) -> None: + """Seek to a position in the current media.""" + await self._zone.set_seek_time(int(position)) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index 534c321e631d6e..eba668563024f0 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -16,7 +16,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" }, "reconfigure": { "description": "Reconfigure your Russound controller.", @@ -37,7 +37,7 @@ "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "wrong_device": "This Russound controller does not match the existing device id. Please make sure you entered the correct IP address." + "wrong_device": "This Russound controller does not match the existing device ID. Please make sure you entered the correct IP address." } }, "exceptions": { diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 27fbfbca57fecb..58925b4b1ffff4 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -1,7 +1,7 @@ { "domain": "russound_rnet", "name": "Russound RNET", - "codeowners": [], + "codeowners": ["@noahhusby"], "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml new file mode 100644 index 00000000000000..b82ef6f464321b --- /dev/null +++ b/homeassistant/components/russound_rnet/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: todo + brands: done + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: todo + comment: | + CI pipeline for publishing is not on GH repo. + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + The device does not support discovery. + discovery: + status: exempt + comment: | + The device does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: | + There are no entities to translate. + exception-translations: todo + icon-translations: + status: exempt + comment: | + There are no entities that require icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. An issue will be implemented for yaml import once a config flow is added. + stale-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + This integration uses telnet or serial exclusively and does not make http calls. + strict-typing: todo diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index c22f100e87acc3..05ca93de9f28a1 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -8,11 +8,11 @@ import aioruuvigateway.api as gw_api from aioruuvigateway.excs import CannotConnect, InvalidAuth -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import DOMAIN from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host @@ -82,7 +82,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Ruuvi Gateway.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 837651f990039a..3f34520e87ade4 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -12,7 +12,6 @@ from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -32,6 +31,14 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info from .const import ( @@ -59,7 +66,7 @@ def _strip_uuid(udn: str) -> str: - return udn[5:] if udn.startswith("uuid:") else udn + return udn.removeprefix("uuid:") def _entry_is_complete( @@ -439,11 +446,11 @@ def _abort_if_manufacturer_is_not_samsung(self) -> None: raise AbortFlow(RESULT_NOT_SUPPORTED) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" + model_name: str = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if discovery_info.ssdp_st == UPNP_SVC_RENDERING_CONTROL: self._ssdp_rendering_control_location = discovery_info.ssdp_location LOGGER.debug( @@ -456,12 +463,10 @@ async def async_step_ssdp( "Set SSDP MainTvAgent location to: %s", self._ssdp_main_tv_agent_location, ) - self._udn = self._upnp_udn = _strip_uuid( - discovery_info.upnp[ssdp.ATTR_UPNP_UDN] - ) + self._udn = self._upnp_udn = _strip_uuid(discovery_info.upnp[ATTR_UPNP_UDN]) if hostname := urlparse(discovery_info.ssdp_location or "").hostname: self._host = hostname - self._manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self._manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self._abort_if_manufacturer_is_not_samsung() # Set defaults, in case they cannot be extracted from device_info @@ -486,7 +491,7 @@ async def async_step_ssdp( return await self.async_step_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) @@ -498,7 +503,7 @@ async def async_step_dhcp( return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a1fda25589ef04..43bd92799a8911 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,7 +35,7 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.9.4", + "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 39c0d6b876dcab..41b2d0d561bb72 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -69,6 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._attr_name = name + self._attr_unique_id = f"satel_alarm_panel_{partition_id}" self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 6ce82908de7a76..9135b58bc50e25 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -58,6 +58,7 @@ class SatelIntegraSwitch(SwitchEntity): def __init__(self, controller, device_number, device_name, code): """Initialize the binary_sensor.""" self._device_number = device_number + self._attr_unique_id = f"satel_switch_{device_number}" self._name = device_name self._state = False self._code = code diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3fa750bf4efe55..4c3e7ad43fe937 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -38,12 +38,12 @@ "description": "The entity ID of the new scene." }, "entities": { - "name": "Entities state", - "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead." + "name": "Entity states", + "description": "List of entities and their target state. If your entities are already in the target state right now, use 'Entities snapshot' instead." }, "snapshot_entities": { - "name": "Snapshot entities", - "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." + "name": "Entities snapshot", + "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine 'Entities snapshot' with 'Entity states'." } } }, @@ -54,7 +54,7 @@ }, "exceptions": { "entity_not_scene": { - "message": "{entity_id} is not a valid scene entity_id." + "message": "{entity_id} is not a valid entity ID of a scene." }, "entity_not_dynamically_created": { "message": "The scene {entity_id} is not created with action `scene.create`." diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 24ce4f3b3fa8b2..30ca44fe3eeb02 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -73,7 +73,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: ) # Check if the from time of the event is after the to time of the previous event - if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable] + if previous_to is not None and previous_to > time_range[CONF_FROM]: raise vol.Invalid("Overlapping times found in schedule") previous_to = time_range[CONF_TO] diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 19db89dc03de8f..54067055a697dd 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -10,7 +10,6 @@ from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL @@ -91,7 +91,7 @@ async def async_step_user( return await self.async_step_gateway_select() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index df6c5ef7acbc20..7a5e910923c2e2 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -9,7 +9,7 @@ from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.chemistry import DOSE_STATE from screenlogicpy.device_const.pump import PUMP_TYPE -from screenlogicpy.device_const.system import EQUIPMENT_FLAG +from screenlogicpy.device_const.system import CONTROLLER_STATE, EQUIPMENT_FLAG from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -41,7 +41,7 @@ class ScreenLogicSensorDescription( ): """Describes a ScreenLogic sensor.""" - value_mod: Callable[[int | str], int | str] | None = None + value_mod: Callable[[int | str], int | str | None] | None = None @dataclasses.dataclass(frozen=True, kw_only=True) @@ -60,6 +60,18 @@ class ScreenLogicPushSensorDescription( state_class=SensorStateClass.MEASUREMENT, translation_key="air_temperature", ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.STATE, + device_class=SensorDeviceClass.ENUM, + options=["ready", "sync", "service"], + value_mod=lambda val: ( + CONTROLLER_STATE(val).name.lower() if val in [1, 2, 3] else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="controller_state", + ), ] SUPPORTED_PUMP_SENSORS = [ @@ -344,7 +356,7 @@ def __init__( ) @property - def native_value(self) -> str | int | float: + def native_value(self) -> str | int | float | None: """State of the sensor.""" val = self.entity_data[ATTR.VALUE] value_mod = self.entity_description.value_mod diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 1f5695d661314b..09e64808dfe4bc 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -184,6 +184,14 @@ "air_temperature": { "name": "Air temperature" }, + "controller_state": { + "name": "Controller state", + "state": { + "ready": "Ready", + "sync": "Sync", + "service": "Service" + } + }, "chem_now": { "name": "{chem} now" }, diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 3834dc4a0c74b3..592b746198ec96 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -45,15 +45,15 @@ "ATTR_OPTION", "ATTR_OPTIONS", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SelectEntity", - "SelectEntityDescription", + "PLATFORM_SCHEMA_BASE", "SERVICE_SELECT_FIRST", "SERVICE_SELECT_LAST", "SERVICE_SELECT_NEXT", "SERVICE_SELECT_OPTION", "SERVICE_SELECT_PREVIOUS", + "SelectEntity", + "SelectEntityDescription", ] # mypy: disallow-any-generics diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py index 248be53ceb7528..35c556a51f2cca 100644 --- a/homeassistant/components/sense/entity.py +++ b/homeassistant/components/sense/entity.py @@ -12,7 +12,7 @@ def sense_to_mdi(sense_icon: str) -> str: """Convert sense icon to mdi icon.""" - return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}" + return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}" class SenseEntity(CoordinatorEntity[SenseCoordinator]): diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 8d47fb115266db..a66ab46c882118 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass +import logging from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -18,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SensiboConfigEntry +from .const import LOGGER from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -122,32 +124,55 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) + + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug( + "New devices: %s, Removed devices: %s, Existing devices: %s", + new_devices, + remove_devices, + added_devices, + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for description in MOTION_DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if device_data.motion_sensors - ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors and device_id in new_devices + for description in MOTION_DEVICE_SENSOR_TYPES + ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index 7adafe2e7fc2c6..df8d4625840a74 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -41,10 +41,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) - for device_id, device_data in coordinator.data.parsed.items() - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index acf58a3bfbf6c0..5d1c6ff9e79b5d 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -3,12 +3,13 @@ from __future__ import annotations from bisect import bisect_left -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.climate import ( ATTR_FAN_MODE, + ATTR_HVAC_MODE, ATTR_SWING_MODE, ClimateEntity, ClimateEntityFeature, @@ -21,8 +22,8 @@ PRECISION_TENTHS, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -39,6 +40,7 @@ SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" SERVICE_FULL_STATE = "full_state" SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react" +SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities" ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold" ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state" ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold" @@ -79,12 +81,28 @@ "horizontal", "both", } +AVAILABLE_HORIZONTAL_SWING_MODES = { + "stopped", + "fixedleft", + "fixedcenterleft", + "fixedcenter", + "fixedcenterright", + "fixedright", + "fixedleftright", + "rangecenter", + "rangefull", + "rangeleft", + "rangeright", + "horizontal", + "both", +} PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { "fanLevel": ClimateEntityFeature.FAN_MODE, "swing": ClimateEntityFeature.SWING_MODE, + "horizontalSwing": ClimateEntityFeature.SWING_HORIZONTAL_MODE, "targetTemperature": ClimateEntityFeature.TARGET_TEMPERATURE, } @@ -105,6 +123,7 @@ "on": "device_on", "mode": "hvac_mode", "swing": "swing_mode", + "horizontalSwing": "horizontal_swing_mode", } @@ -125,12 +144,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities = [ - SensiboClimate(coordinator, device_id) - for device_id, device_data in coordinator.data.parsed.items() - ] + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) - async_add_entities(entities) + if new_devices: + async_add_entities( + SensiboClimate(coordinator, device_id) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -172,7 +201,6 @@ async def async_setup_entry( }, "async_full_ac_state", ) - platform.async_register_entity_service( SERVICE_ENABLE_CLIMATE_REACT, { @@ -181,11 +209,17 @@ async def async_setup_entry( vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, vol.Required(ATTR_SMART_TYPE): vol.In( - ["temperature", "feelsLike", "humidity"] + ["temperature", "feelslike", "humidity"] ), }, "async_enable_climate_react", ) + platform.async_register_entity_service( + SERVICE_GET_DEVICE_CAPABILITIES, + {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, + "async_get_device_capabilities", + supports_response=SupportsResponse.ONLY, + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -231,8 +265,8 @@ def hvac_mode(self) -> HVACMode: @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - if not self.device_data.hvac_modes: - return [HVACMode.OFF] + if TYPE_CHECKING: + assert self.device_data.hvac_modes return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] @property @@ -285,6 +319,16 @@ def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" return self.device_data.swing_modes + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + return self.device_data.horizontal_swing_mode + + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes.""" + return self.device_data.horizontal_swing_modes + @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -372,6 +416,26 @@ async def async_set_swing_mode(self, swing_mode: str) -> None: transformation=transformation, ) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + if swing_horizontal_mode not in AVAILABLE_HORIZONTAL_SWING_MODES: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="horizontal_swing_not_supported", + translation_placeholders={ + "horizontal_swing_mode": swing_horizontal_mode + }, + ) + + transformation = self.device_data.horizontal_swing_modes_translated + await self.async_send_api_call( + key=AC_STATE_TO_DATA["horizontalSwing"], + value=swing_horizontal_mode, + name="horizontalSwing", + assumed_state=False, + transformation=transformation, + ) + async def async_turn_on(self) -> None: """Turn Sensibo unit on.""" await self.async_send_api_call( @@ -390,6 +454,26 @@ async def async_turn_off(self) -> None: assumed_state=False, ) + async def async_get_device_capabilities( + self, hvac_mode: HVACMode + ) -> dict[str, Any]: + """Get capabilities from device.""" + active_features = self.device_data.active_features + mode_capabilities: dict[str, Any] | None = self.device_data.full_capabilities[ + "modes" + ].get(hvac_mode.value) + if not mode_capabilities: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="mode_not_exist" + ) + remote_capabilities: dict[str, Any] = {} + for active_feature in active_features: + if active_feature in mode_capabilities: + remote_capabilities[active_feature.lower()] = mode_capabilities[ + active_feature + ] + return remote_capabilities + async def async_assume_state(self, state: str) -> None: """Sync state with api.""" await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e512935dfcec92..e19f24295b9573 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -48,6 +49,25 @@ def __init__(self, hass: HomeAssistant, config_entry: SensiboConfigEntry) -> Non session=async_get_clientsession(hass), timeout=TIMEOUT, ) + self.previous_devices: set[str] = set() + + def get_devices( + self, added_devices: set[str] + ) -> tuple[set[str], set[str], set[str]]: + """Addition and removal of devices.""" + data = self.data + motion_sensors = { + sensor_id + for device_data in data.parsed.values() + if device_data.motion_sensors + for sensor_id in device_data.motion_sensors + } + devices: set[str] = set(data.parsed) + new_devices: set[str] = motion_sensors | devices - added_devices + remove_devices = added_devices - devices - motion_sensors + added_devices = (added_devices - remove_devices) | new_devices + + return (new_devices, remove_devices, added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" @@ -67,4 +87,23 @@ async def _async_update_data(self) -> SensiboData: if not data.raw: raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data") + + current_devices = set(data.parsed) + for device_data in data.parsed.values(): + if device_data.motion_sensors: + for motion_sensor_id in device_data.motion_sensors: + current_devices.add(motion_sensor_id) + + if stale_devices := self.previous_devices - current_devices: + LOGGER.debug("Removing stale devices: %s", stale_devices) + device_registry = dr.async_get(self.hass) + for _id in stale_devices: + device = device_registry.async_get_device(identifiers={(DOMAIN, _id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.previous_devices = current_devices + return data diff --git a/homeassistant/components/sensibo/icons.json b/homeassistant/components/sensibo/icons.json index ccab3c198d2c0c..f97f0f1e80d418 100644 --- a/homeassistant/components/sensibo/icons.json +++ b/homeassistant/components/sensibo/icons.json @@ -59,6 +59,9 @@ }, "enable_climate_react": { "service": "mdi:wizard-hat" + }, + "get_device_capabilities": { + "service": "mdi:shape-outline" } } } diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index baa056f0eeac33..aa46c7f8c1e71c 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -71,11 +71,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboNumber(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_NUMBER_TYPES - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboNumber(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in DEVICE_NUMBER_TYPES + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index 08632ddac0fad5..c21cf100e9dfe7 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: status: done comment: | @@ -62,7 +62,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: status: exempt comment: | diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 4cc1426743c39c..51521b59f030c5 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,10 +8,21 @@ from pysensibo.model import SensiboDevice -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import SensiboConfigEntry from .const import DOMAIN @@ -31,15 +42,17 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] +HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( + key="horizontalSwing", + data_key="horizontal_swing_mode", + value_fn=lambda data: data.horizontal_swing_mode, + options_fn=lambda data: data.horizontal_swing_modes, + translation_key="horizontalswing", + transformation=lambda data: data.horizontal_swing_modes_translated, + entity_registry_enabled_default=False, +) + DEVICE_SELECT_TYPES = ( - SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - ), SensiboSelectEntityDescription( key="light", data_key="light_mode", @@ -60,12 +73,61 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboSelect(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_SELECT_TYPES - if description.key in device_data.full_features - ) + entities: list[SensiboSelect] = [] + + entity_registry = er.async_get(hass) + for device_id, device_data in coordinator.data.parsed.items(): + if entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" + ): + entity = entity_registry.async_get(entity_id) + if entity and entity.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + ) + elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: + entities.append( + SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) + ) + if automations_with_entity(hass, entity_id) or scripts_with_entity( + hass, entity_id + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity_horizontalswing", + translation_placeholders={ + "name": str(entity.name or entity.original_name), + "entity": entity_id, + }, + ) + async_add_entities(entities) + + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboSelect(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SELECT_TYPES + if description.key in device_data.full_features + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): @@ -84,6 +146,13 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.key not in self.device_data.active_features: + return False + return super().available + @property def current_option(self) -> str | None: """Return the current selected option.""" @@ -99,17 +168,6 @@ def options(self) -> list[str]: async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" - if self.entity_description.key not in self.device_data.active_features: - hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_not_available", - translation_placeholders={ - "hvac_mode": hvac_mode, - "key": self.entity_description.key, - }, - ) - await self.async_send_api_call( key=self.entity_description.data_key, value=option, diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index b395f8eb1eeb5a..b242f38febe8e4 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,6 +36,13 @@ PARALLEL_UPDATES = 0 +def _smart_type_name(_type: str | None) -> str | None: + """Return a lowercase name of smart type.""" + if _type and _type == "feelsLike": + return "feelslike" + return _type + + @dataclass(frozen=True, kw_only=True) class SensiboMotionSensorEntityDescription(SensorEntityDescription): """Describes Sensibo Motion sensor entity.""" @@ -153,7 +160,7 @@ class SensiboDeviceSensorEntityDescription(SensorEntityDescription): SensiboDeviceSensorEntityDescription( key="climate_react_type", translation_key="smart_type", - value_fn=lambda data: data.smart_type, + value_fn=lambda data: _smart_type_name(data.smart_type), extra_fn=None, entity_registry_enabled_default=False, ), @@ -239,25 +246,40 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 7d2a644edfd998..071f8c65609706 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -159,3 +159,21 @@ enable_climate_react: - "feelslike" - "humidity" translation_key: smart_type +get_device_capabilities: + target: + entity: + integration: sensibo + domain: climate + fields: + hvac_mode: + required: true + example: "heat" + selector: + select: + options: + - "auto" + - "cool" + - "dry" + - "fan" + - "heat" + translation_key: hvac_mode diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 4e26dbd37d385c..c5ff0f135e6da1 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -387,6 +387,21 @@ "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } + }, + "swing_horizontal_mode": { + "state": { + "stopped": "[%key:common::state::off%]", + "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + } } } } @@ -470,8 +485,8 @@ } }, "enable_climate_react": { - "name": "Enable climate react", - "description": "Enables and configures climate react.", + "name": "Enable Climate React", + "description": "Enables and configures Climate React.", "fields": { "high_temperature_threshold": { "name": "Threshold high", @@ -494,6 +509,16 @@ "description": "Choose between temperature/feels like/humidity." } } + }, + "get_device_capabilities": { + "name": "Get device mode capabilities", + "description": "Retrieves the device capabilities for a specific device according to API requirements.", + "fields": { + "hvac_mode": { + "name": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::name%]", + "description": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::description%]" + } + } } }, "selector": { @@ -541,17 +566,17 @@ "swing_not_supported": { "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" }, + "horizontal_swing_not_supported": { + "message": "Climate horizontal swing mode {horizontal_swing_mode} is not supported by the integration, please open an issue" + }, "service_result_not_true": { "message": "Could not perform action for {name}" }, "service_raised": { "message": "Could not perform action for {name} with error {error}" }, - "select_option_not_available": { - "message": "Current mode {hvac_mode} doesn't support setting {key}" - }, "climate_react_not_available": { - "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app" + "message": "Use the Sensibo 'Enable Climate React' action once to enable the switch, or use the Sensibo app" }, "auth_error": { "message": "Authentication failed, please update your API key" @@ -561,6 +586,15 @@ }, "no_data": { "message": "[%key:component::sensibo::config::error::no_devices%]" + }, + "mode_not_exist": { + "message": "The entity does not support the chosen mode" + } + }, + "issues": { + "deprecated_entity_horizontalswing": { + "title": "The Sensibo {name} entity is deprecated", + "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 46906ac1871f4d..0bc2c55a706b2f 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -84,13 +84,25 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceSwitch(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SWITCH_TYPES - ) - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceSwitch(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SWITCH_TYPES + ) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index d52565564a656f..0b02264b3e0b1a 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -51,12 +51,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceUpdate(coordinator, device_id, description) - for description in DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if description.value_available(device_data) is not None - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceUpdate(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SENSOR_TYPES + if description.value_available(device_data) is not None + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2933d779b4b8b9..37df50b2099b5f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -67,8 +67,8 @@ "CONF_STATE_CLASS", "DEVICE_CLASS_STATE_CLASSES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "RestoreSensor", "SensorDeviceClass", "SensorEntity", diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8c3c39255135c0..aaa14f4637c2fd 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -392,7 +392,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e0d9d17d55d452..5ca58ec7d01cbd 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -15,6 +15,7 @@ from aioshelly.rpc_device import RpcDevice import voluptuous as vol +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -331,3 +332,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b return await hass.config_entries.async_unload_platforms( entry, runtime_data.platforms ) + + +async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> None: + """Remove a config entry.""" + if get_device_entry_gen(entry) in RPC_GENERATIONS and ( + mac_address := entry.unique_id + ): + async_remove_scanner(hass, mac_address) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index f2b71d19d615de..5200ec9b913f9b 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -28,7 +28,13 @@ async def async_connect_scanner( source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=entry.domain, + source_model=coordinator.model, + source_config_entry_id=entry.entry_id, + ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 842abc5ecc4c9a..f8e157a6a5d589 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,7 @@ from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,7 +22,11 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity @@ -31,6 +35,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + BLU_TRV_TEMPERATURE_SETTINGS, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -124,6 +129,7 @@ def async_setup_rpc_entry( coordinator = config_entry.runtime_data.rpc assert coordinator climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER) climate_ids = [] for id_ in climate_key_ids: @@ -139,10 +145,11 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) - if not climate_ids: - return + if climate_ids: + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) - async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + if blutrv_key_ids: + async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) @dataclass @@ -526,3 +533,75 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.call_rpc( "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} ) + + +class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + + super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") + self._id = id_ + self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = self._config["addr"] + self._attr_unique_id = f"{ble_addr}-{self.key}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}" + model_id = self._config.get("local_name") + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, self.coordinator.mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id), + model_id=model_id, + name=name, + ) + # Added intentionally to the constructor to avoid double name from base class + self._attr_name = None + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + if not self._config["enable"]: + return None + + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["pos"]: + return HVACAction.IDLE + + return HVACAction.HEATING + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "BluTRV.Call", + { + "id": self._id, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": target_temp}, + }, + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 55686464637da8..f53da8bd766b6b 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -17,7 +17,6 @@ from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -34,6 +33,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_BLE_SCANNER_MODE, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 88d8c1f5f17ee3..1adaad8f975fc9 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -187,6 +187,13 @@ "step": 0.5, } +BLU_TRV_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 30, + "step": 0.1, + "default": 20.0, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f20b283cacf9b0..e6129b5559ae64 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -20,6 +20,7 @@ from aioshelly.rpc_device import RpcDevice, RpcUpdateType from propcache import cached_property +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, @@ -30,7 +31,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth import async_connect_scanner @@ -154,6 +155,7 @@ def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: config_entry_id=self.entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=MODEL_NAMES.get(self.model), model_id=self.model, @@ -371,7 +373,7 @@ async def _async_update_data(self) -> None: try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -456,7 +458,7 @@ async def _async_update_data(self) -> None: return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -696,6 +698,7 @@ async def _async_connect_ble_scanner(self) -> None: ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) + async_remove_scanner(self.hass, format_mac(self.mac).upper()) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index fbf72e6ebe8cdd..e18cd7ca465a1b 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -42,7 +42,7 @@ def async_describe_shelly_click_event(event: Event) -> dict[str, str]: if click_type in RPC_INPUTS_EVENTS_TYPES: rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: - key = f"input:{channel-1}" + key = f"input:{channel - 1}" input_name = get_rpc_entity_name(rpc_coordinator.device, key) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 29c8fd4c369c69..cf5c59da5e3a75 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.2.0"], + "requirements": ["aioshelly==12.3.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 03ce080db8e83f..139a427f0870fa 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -770,6 +770,18 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "ret_energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="ret_aenergy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "energy_cct": RpcSensorDescription( key="cct", sub_key="aenergy", diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 134704cb0ffce2..8a33dae09383dc 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -120,9 +120,8 @@ def async_setup_block_entry( relay_blocks = [] assert coordinator.device.blocks for block in coordinator.device.blocks: - if ( - block.type != "relay" - or block.channel is not None + if block.type != "relay" or ( + block.channel is not None and is_block_channel_type_light( coordinator.device.settings, int(block.channel) ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index df374624e3d70b..d450727ead699f 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -137,7 +137,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: else: base = ord("1") - return f"{entity_name} channel {chr(int(block.channel)+base)}" + return f"{entity_name} channel {chr(int(block.channel) + base)}" def is_block_momentary_input( @@ -200,7 +200,7 @@ def get_block_input_triggers( subtype = "button" else: assert block.channel - subtype = f"button{int(block.channel)+1}" + subtype = f"button{int(block.channel) + 1}" if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES @@ -409,7 +409,7 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: continue for trigger_type in RPC_INPUTS_EVENTS_TYPES: - subtype = f"button{id_+1}" + subtype = f"button{id_ + 1}" triggers.append((trigger_type, subtype)) return triggers diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 9ce6898fd93560..02b49f5732e29d 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -68,10 +68,8 @@ def process_turn_on_params( isinstance(siren.available_tones, dict) and tone in siren.available_tones.values() ) - if ( - not siren.available_tones - or tone not in siren.available_tones - and not is_tone_dict_value + if not siren.available_tones or ( + tone not in siren.available_tones and not is_tone_dict_value ): raise ValueError( f"Invalid tone specified for entity {siren.entity_id}: {tone}, " diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 6fce38e4774d7d..aa67739016dfa3 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,8 +5,8 @@ import logging from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform @@ -40,7 +40,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) - slack = WebClient(token=entry.data[CONF_API_KEY], run_async=True, session=session) + slack = AsyncWebClient( + token=entry.data[CONF_API_KEY], session=session + ) # No run_async try: res = await slack.auth_test() @@ -49,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Invalid API key") return False raise ConfigEntryNotReady("Error while setting up integration") from ex + data = { DATA_CLIENT: slack, ATTR_URL: res[ATTR_URL], diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 7f6d72886067e6..fcdc2e8b362d01 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,8 +4,8 @@ import logging -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,10 +57,10 @@ async def async_step_user( async def _async_try_connect( self, token: str - ) -> tuple[str, None] | tuple[None, dict[str, str]]: + ) -> tuple[str, None] | tuple[None, AsyncSlackResponse]: """Try connecting to Slack.""" session = aiohttp_client.async_get_clientsession(self.hass) - client = WebClient(token=token, run_async=True, session=session) + client = AsyncWebClient(token=token, session=session) # No run_async try: info = await client.auth_test() diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 7147186ee9baf0..30218360054fda 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,21 +14,18 @@ class SlackEntity(Entity): """Representation of a Slack entity.""" - _attr_attribution = "Data provided by Slack" - _attr_has_entity_name = True - def __init__( self, - data: dict[str, str | WebClient], + data: dict[str, AsyncWebClient], description: EntityDescription, entry: ConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client = data[DATA_CLIENT] + self._client: AsyncWebClient = data[DATA_CLIENT] self.entity_description = description self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=data[ATTR_URL], + configuration_url=str(data[ATTR_URL]), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 1b35db6f061dee..3b2322283fe5c0 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["slack"], - "requirements": ["slackclient==2.5.0"] + "requirements": ["slack_sdk==3.33.4"] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 28f9dd203ff993..16dd212301a4af 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -5,13 +5,13 @@ import asyncio import logging import os -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import urlparse -from aiohttp import BasicAuth, FormData +from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol from homeassistant.components.notify import ( @@ -38,6 +38,7 @@ DATA_CLIENT, SLACK_DATA, ) +from .utils import upload_file_to_slack _LOGGER = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class SlackNotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, - client: WebClient, + client: AsyncWebClient, config: dict[str, str], ) -> None: """Initialize.""" @@ -160,17 +161,23 @@ async def _async_send_local_file_message( parsed_url = urlparse(path) filename = os.path.basename(parsed_url.path) - try: - await self._client.files_upload( - channels=",".join(targets), - file=path, - filename=filename, - initial_comment=message, - title=title or filename, - thread_ts=thread_ts or "", - ) - except (SlackApiError, ClientError) as err: - _LOGGER.error("Error while uploading file-based message: %r", err) + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values + + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return + + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=None, + file_path=path, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_remote_file_message( self, @@ -183,12 +190,7 @@ async def _async_send_remote_file_message( username: str | None = None, password: str | None = None, ) -> None: - """Upload a remote file (with message) to Slack. - - Note that we bypass the python-slackclient WebClient and use aiohttp directly, - as the former would require us to download the entire remote file into memory - first before uploading it to Slack. - """ + """Upload a remote file (with message) to Slack.""" if not self._hass.config.is_allowed_external_url(url): _LOGGER.error("URL is not allowed: %s", url) return @@ -196,36 +198,35 @@ async def _async_send_remote_file_message( filename = _async_get_filename_from_url(url) session = aiohttp_client.async_get_clientsession(self._hass) + # Fetch the remote file kwargs: AuthDictT = {} - if username and password is not None: + if username and password: kwargs = {"auth": BasicAuth(username, password=password)} - resp = await session.request("get", url, **kwargs) - try: - resp.raise_for_status() + async with session.get(url, **kwargs) as resp: + resp.raise_for_status() + file_content = await resp.read() except ClientError as err: _LOGGER.error("Error while retrieving %s: %r", url, err) return - form_data: FormDataT = { - "channels": ",".join(targets), - "filename": filename, - "initial_comment": message, - "title": title or filename, - "token": self._client.token, - } - - if thread_ts: - form_data["thread_ts"] = thread_ts + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values - data = FormData(form_data, charset="utf-8") - data.add_field("file", resp.content, filename=filename) + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return - try: - await session.post("https://slack.com/api/files.upload", data=data) - except ClientError as err: - _LOGGER.error("Error while uploading file message: %r", err) + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=file_content, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_text_only_message( self, @@ -327,3 +328,46 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: title, thread_ts=data.get(ATTR_THREAD_TS), ) + + async def _async_get_channel_id(self, channel_name: str) -> str | None: + """Get the Slack channel ID from the channel name. + + This method retrieves the channel ID for a given Slack channel name by + querying the Slack API. It handles both public and private channels. + Including this so users can provide channel names instead of IDs. + + Args: + channel_name (str): The name of the Slack channel. + + Returns: + str | None: The ID of the Slack channel if found, otherwise None. + + Raises: + SlackApiError: If there is an error while communicating with the Slack API. + + """ + try: + # Remove # if present + channel_name = channel_name.lstrip("#") + + # Get channel list + # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test + # response = await self._client.conversations_list(types="public_channel,private_channel") + # + # Workaround for the types parameter not working + channels = [] + for channel_type in ("public_channel", "private_channel"): + response = await self._client.conversations_list(types=channel_type) + channels.extend(response["channels"]) + + # Find channel ID + for channel in channels: + if channel["name"] == channel_name: + return cast(str, channel["id"]) + + _LOGGER.error("Channel %s not found", channel_name) + + except SlackApiError as err: + _LOGGER.error("Error getting channel ID: %r", err) + + return None diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 9e3beaadd8b03e..d53555ba82ab80 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -43,7 +43,7 @@ async def async_setup_entry( class SlackSensorEntity(SlackEntity, SensorEntity): """Representation of a Slack sensor.""" - _client: WebClient + _client: AsyncWebClient async def async_update(self) -> None: """Get the latest status.""" diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py new file mode 100644 index 00000000000000..7619d7d265ffd1 --- /dev/null +++ b/homeassistant/components/slack/utils.py @@ -0,0 +1,62 @@ +"""Utils for the Slack integration.""" + +import logging + +import aiofiles +from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient + +_LOGGER = logging.getLogger(__name__) + + +async def upload_file_to_slack( + client: AsyncWebClient, + channel_ids: list[str | None], + file_content: bytes | str | None, + filename: str, + title: str | None, + message: str, + thread_ts: str | None, + file_path: str | None = None, # Allow passing a file path +) -> None: + """Upload a file to Slack for the specified channel IDs. + + Args: + client (AsyncWebClient): The Slack WebClient instance. + channel_ids (list[str | None]): List of channel IDs to upload the file to. + file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used. + filename (str): The file's name. + title (str | None): Title of the file in Slack. + message (str): Initial comment to accompany the file. + thread_ts (str | None): Thread timestamp for threading messages. + file_path (str | None): Path to the local file to be read if file_content is None. + + Raises: + SlackApiError: If the Slack API call fails. + OSError: If there is an error reading the file. + + """ + if file_content is None and file_path: + # Read file asynchronously if file_content is not provided + try: + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + except OSError as os_err: + _LOGGER.error("Error reading file %s: %r", file_path, os_err) + return + + for channel_id in channel_ids: + try: + await client.files_upload_v2( + channel=channel_id, + file=file_content, + filename=filename, + title=title or filename, + initial_comment=message, + thread_ts=thread_ts or "", + ) + _LOGGER.info("Successfully uploaded file to channel %s", channel_id) + except SlackApiError as err: + _LOGGER.error( + "Error while uploading file to channel %s: %r", channel_id, err + ) diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index 795cd4f1c2e2af..faca7cb3f2b21b 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -44,7 +44,7 @@ class SlideButton(SlideEntity, ButtonEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide button.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate" + self._attr_unique_id = f"{coordinator.data['mac']}-calibrate" async def async_press(self) -> None: """Send out a calibrate command.""" diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index a4255f0769f7c8..4ceb347568f594 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,11 +14,11 @@ ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DOMAIN @@ -63,7 +63,7 @@ async def async_test_connection( return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} @@ -85,7 +85,7 @@ async def async_test_connection( return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json index b5fe88255a7475..67514ff0d50e5b 100644 --- a/homeassistant/components/slide_local/strings.json +++ b/homeassistant/components/slide_local/strings.json @@ -32,7 +32,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "discovery_connection_failed": "The setup of the discovered device failed with the following error: {error}. Please try to set it up manually.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "unique_id_mismatch": "The mac address of the device ({mac}) does not match the previous mac address." + "unique_id_mismatch": "The MAC address of the device ({mac}) does not match the previous MAC address." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -49,7 +49,7 @@ "invert_position": "Invert position" }, "data_description": { - "invert_position": "Invert the position of your slide cover." + "invert_position": "Inverts the position of your Slide cover." } } } diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index f1c33f9a76f7c8..0471dfcc4e63d4 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -47,7 +47,7 @@ class SlideSwitch(SlideEntity, SwitchEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide switch.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo" + self._attr_unique_id = f"{coordinator.data['mac']}-touchgo" @property def is_on(self) -> bool: diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 37fb4d72284aa6..6aae74922e4a14 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -72,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=sma_device_info["type"], name=sma_device_info["name"], sw_version=sma_device_info["sw_version"], + serial_number=sma_device_info["serial"], ) # Define the coordinator diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py new file mode 100644 index 00000000000000..9c17cb0d2a9638 --- /dev/null +++ b/homeassistant/components/sma/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for SMA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + entry_dict = entry.as_dict() + if "data" in entry_dict: + entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT) + + return { + "entry": entry_dict, + "entities": entity_states, + } diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 070320fa976354..8024aad82d6b5a 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -1,10 +1,10 @@ { "domain": "sma", "name": "SMA Solar", - "codeowners": ["@kellerza", "@rklomp"], + "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], - "requirements": ["pysma==0.7.3"] + "requirements": ["pysma==0.7.5"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 302c4f6b197419..863f15a9a17bed 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -48,6 +48,12 @@ entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + "operating_status": SensorEntityDescription( + key="operating_status", + name="Operating Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), "inverter_condition": SensorEntityDescription( key="inverter_condition", name="Inverter Condition", diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index 4f7a71218abbb5..01b69a76b28828 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -6,10 +6,10 @@ from pysmappee import helper, mqtt import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import api from .const import ( @@ -43,7 +43,7 @@ def logger(self) -> logging.Logger: return logging.getLogger(__name__) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173998..2914851ccbfa4a 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + 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 @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def retrieve_device_status(device): broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e08c..7b49854740a9bd 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ async def async_step_authorize( url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ def _show_step_pat(self, errors): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132d85..76b6804075fdcb 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def delete_subscription(sub: SubscriptionEntity): _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd635..31a552be149598 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,7 +7,7 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } @@ -17,11 +17,20 @@ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 94bdfcc4559129..59b329488792a6 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -32,6 +32,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + + if entry.version > 3: + # Downgrade from future version + return False + if entry.version == 1: new_data = { CONF_NAME: entry.data[CONF_NAME], @@ -40,8 +45,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_LONGITUDE: entry.data[CONF_LONGITUDE], }, } + hass.config_entries.async_update_entry(entry, data=new_data, version=2) - if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): - return False + if entry.version == 2: + new_data = entry.data.copy() + new_data.pop(CONF_NAME) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2992b176f242e3..2521df3a3331f1 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -38,7 +38,7 @@ async def async_check_location( class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 2 + VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -58,10 +58,6 @@ async def async_step_user( ): name = HOME_LOCATION_NAME - user_input[CONF_NAME] = ( - HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME - ) - await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 76f9812e8158b8..645ace41cab240 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.18"] + "requirements": ["smhi-pkg==1.0.19"] } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 3d5642a27847a3..d43ca4465aef37 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -48,7 +48,6 @@ CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -60,7 +59,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util, slugify +from homeassistant.util import Throttle, dt as dt_util from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -103,17 +102,15 @@ async def async_setup_entry( ) -> None: """Add a weather entity from map location.""" location = config_entry.data - name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) entity = SmhiWeather( - location[CONF_NAME], location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], session=session, ) - entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) + entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) async_add_entities([entity], True) @@ -136,7 +133,6 @@ class SmhiWeather(WeatherEntity): def __init__( self, - name: str, latitude: str, longitude: str, session: aiohttp.ClientSession, @@ -152,7 +148,6 @@ def __init__( identifiers={(DOMAIN, f"{latitude}, {longitude}")}, manufacturer="SMHI", model="v2", - name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 92b543e04410ca..1a222f1b21feef 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -9,11 +9,11 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -82,7 +82,7 @@ async def async_step_auth( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" local_name = discovery_info.hostname[:-1] diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 669094b2441bdf..0a45363f8adf92 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,7 +9,7 @@ DATA_COORDINATOR = "data" FIRMWARE_COORDINATOR = "firmware" -SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) +SCAN_FIRMWARE_INTERVAL = timedelta(hours=24) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index cb791ac111bded..6518cc81989c12 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.4"], + "requirements": ["pysmlight==0.1.5"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 3c4a0a0725c6c3..4c2b2b25ad8da7 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -172,7 +172,7 @@ async def async_get_snmp_data(self): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and res[int(errindex) - 1][0] or "?", + (errindex and res[int(errindex) - 1][0]) or "?", ) return None diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 92e27daed6c3fb..2f9f8b0bfb74bf 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -264,7 +264,7 @@ async def async_update(self) -> None: _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and restable[-1][int(errindex) - 1] or "?", + (errindex and restable[-1][int(errindex) - 1]) or "?", ) else: for resrow in restable: diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 631ace3792ffa1..925f11e4c65407 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==3.2.1"] + "requirements": ["solax==3.2.3"] } diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 50f7d34e406f0a..e64fee00f16c8e 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -76,7 +76,7 @@ def close_cover_tilt(self, **kwargs: Any) -> None: response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) self.set_position(0) @@ -85,7 +85,7 @@ def open_cover_tilt(self, **kwargs: Any) -> None: response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) self.set_position(100) @@ -94,7 +94,7 @@ def stop_cover_tilt(self, **kwargs: Any) -> None: response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -109,7 +109,7 @@ def set_cover_tilt_position(self, **kwargs: Any) -> None: if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) self.set_position(kwargs[ATTR_TILT_POSITION]) @@ -152,7 +152,7 @@ def close_cover(self, **kwargs: Any) -> None: response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) def open_cover(self, **kwargs: Any) -> None: @@ -160,7 +160,7 @@ def open_cover(self, **kwargs: Any) -> None: response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) def stop_cover(self, **kwargs: Any) -> None: @@ -168,7 +168,7 @@ def stop_cover(self, **kwargs: Any) -> None: response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -182,7 +182,7 @@ def set_cover_position(self, **kwargs: Any) -> None: if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) async def async_update(self) -> None: diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index c2d851601750b2..a806d581aec274 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -9,7 +9,6 @@ from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -21,6 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_REVERSE, @@ -69,7 +69,7 @@ def __init__(self) -> None: self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index bdb647de39cef0..f25c885ed847a6 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -67,7 +67,7 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" ) attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" return attrs @@ -120,7 +120,8 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: value_fn=len, attributes_fn=lambda data: { i.title: ( - f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes" + f"{getattr(i.statistics, 'episodeFileCount', 0)}/" + f"{getattr(i.statistics, 'episodeCount', 0)} Episodes" ) for i in data }, diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 1c13013108f82f..e71454f0aa82a2 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -9,9 +9,13 @@ from songpal import Device, SongpalException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_ENDPOINT, DOMAIN @@ -99,15 +103,15 @@ async def async_step_init( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Songpal device.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() _LOGGER.debug("Discovered: %s", discovery_info) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] hostname = urlparse(discovery_info.ssdp_location).hostname scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 82e4a5ebfba173..98bff8d293433e 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,6 +34,7 @@ ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -500,7 +501,7 @@ async def _async_handle_discovery_message( @callback def _async_ssdp_discovered_player( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: uid = info.upnp[ssdp.ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): @@ -529,7 +530,7 @@ def _async_ssdp_discovered_player( def async_discovered_player( self, source: str, - info: ssdp.SsdpServiceInfo, + info: SsdpServiceInfo, discovered_ip: str, uid: str, boot_seqnum: str | int | None, diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index a8ace6e35c56de..66fe0f0d78cab3 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -3,10 +3,11 @@ from collections.abc import Awaitable import dataclasses -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN, UPNP_ST from .helpers import hostname_to_uid @@ -25,7 +26,7 @@ def __init__(self) -> None: super().__init__(DOMAIN, "Sonos", _async_has_devices) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf.""" hostname = discovery_info.hostname diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index af45b8f6bdc756..f30065d1157995 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -6,10 +6,10 @@ from requests import RequestException import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 9fc11f7788a94a..2544eeb14a9b14 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -27,8 +27,8 @@ "description": "Plays on all Bose SoundTouch devices.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." + "name": "Leader", + "description": "The media player entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." } } }, @@ -37,40 +37,40 @@ "description": "Creates a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent." + "name": "Leader", + "description": "The media player entity that will coordinate the multi-room zone. Platform dependent." }, "slaves": { - "name": "Slaves", - "description": "Name of slaves entities to add to the new zone." + "name": "Follower", + "description": "The media player entities to add to the new zone." } } }, "add_zone_slave": { - "name": "Add zone slave", - "description": "Adds a slave to a SoundTouch multi-room zone.", + "name": "Add zone follower", + "description": "Adds media players to a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent." + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", + "description": "The media player entity that is coordinating the multi-room zone. Platform dependent." }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to add to the existing zone." + "description": "The media player entities to add to the existing zone." } } }, "remove_zone_slave": { - "name": "Remove zone slave", - "description": "Removes a slave from the SoundTouch multi-room zone.", + "name": "Remove zone follower", + "description": "Removes media players from a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", "description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]" }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to remove from the existing zone." + "description": "The media player entities to remove from the existing zone." } } } diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 37580ac432dcaf..663b3f30caaf9b 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -32,11 +32,11 @@ PLATFORMS = [Platform.MEDIA_PLAYER] __all__ = [ - "async_browse_media", "DOMAIN", - "spotify_uri_from_media_browser_url", + "async_browse_media", "is_spotify_media_type", "resolve_spotify_media_type", + "spotify_uri_from_media_browser_url", ] diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 81cdfdfb3cfb07..458525dde28ed1 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -14,7 +14,7 @@ SpotifyClient, Track, ) -from spotifyaio.models import ItemType, SimplifiedEpisode +from spotifyaio.models import Episode, ItemType, SimplifiedEpisode import yarl from homeassistant.components.media_player import ( @@ -363,7 +363,7 @@ async def build_item_response( # noqa: C901 items.append(_get_track_item_payload(playlist_item.track)) elif playlist_item.track.type is ItemType.EPISODE: if TYPE_CHECKING: - assert isinstance(playlist_item.track, SimplifiedEpisode) + assert isinstance(playlist_item.track, Episode) items.append(_get_episode_item_payload(playlist_item.track)) elif media_content_type == MediaType.ALBUM: if album := await spotify.get_album(media_content_id): diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index f466f3bcb62c06..f94ea118c6a4a8 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -105,9 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - lms.name = ( (STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME]) and status[STATUS_QUERY_LIBRARYNAME] - or host - ) - version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None + ) or host + version = (STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION]) or None # mac can be missing mac_connect = ( {(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))} diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index c372c7262d456c..97eb848c21c4ec 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -10,7 +10,6 @@ from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -18,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN @@ -200,7 +200,7 @@ async def async_step_integration_discovery( return await self.async_step_edit() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ccd69961975d65..c5fb349ddbb6ae 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable, Coroutine, Mapping -from dataclasses import dataclass, field from datetime import timedelta from enum import Enum from functools import partial @@ -44,13 +43,36 @@ __version__ as current_version, ) from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT as _ATTR_NT, + ATTR_ST as _ATTR_ST, + ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + ATTR_UPNP_UPC as _ATTR_UPNP_UPC, + SsdpServiceInfo as _SsdpServiceInfo, +) from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass @@ -77,30 +99,90 @@ ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description -ATTR_ST = "st" -ATTR_NT = "nt" -ATTR_UPNP_DEVICE_TYPE = "deviceType" -ATTR_UPNP_FRIENDLY_NAME = "friendlyName" -ATTR_UPNP_MANUFACTURER = "manufacturer" -ATTR_UPNP_MANUFACTURER_URL = "manufacturerURL" -ATTR_UPNP_MODEL_DESCRIPTION = "modelDescription" -ATTR_UPNP_MODEL_NAME = "modelName" -ATTR_UPNP_MODEL_NUMBER = "modelNumber" -ATTR_UPNP_MODEL_URL = "modelURL" -ATTR_UPNP_SERIAL = "serialNumber" -ATTR_UPNP_SERVICE_LIST = "serviceList" -ATTR_UPNP_UDN = "UDN" -ATTR_UPNP_UPC = "UPC" -ATTR_UPNP_PRESENTATION_URL = "presentationURL" +_DEPRECATED_ATTR_ST = DeprecatedConstant( + _ATTR_ST, + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + "2026.2", +) +_DEPRECATED_ATTR_NT = DeprecatedConstant( + _ATTR_NT, + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant( + _ATTR_UPNP_DEVICE_TYPE, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant( + _ATTR_UPNP_FRIENDLY_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant( + _ATTR_UPNP_MODEL_DESCRIPTION, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant( + _ATTR_UPNP_MODEL_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant( + _ATTR_UPNP_MODEL_NUMBER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant( + _ATTR_UPNP_MODEL_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant( + _ATTR_UPNP_SERIAL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant( + _ATTR_UPNP_SERVICE_LIST, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant( + _ATTR_UPNP_UDN, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant( + _ATTR_UPNP_UPC, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( + _ATTR_UPNP_PRESENTATION_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + "2026.2", +) # Attributes for accessing info added by Home Assistant ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains" PRIMARY_MATCH_KEYS = [ - ATTR_UPNP_MANUFACTURER, - ATTR_ST, - ATTR_UPNP_DEVICE_TYPE, - ATTR_NT, - ATTR_UPNP_MANUFACTURER_URL, + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, ] _LOGGER = logging.getLogger(__name__) @@ -108,27 +190,16 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) - -@dataclass(slots=True) -class SsdpServiceInfo(BaseServiceInfo): - """Prepared info from ssdp/upnp entries.""" - - ssdp_usn: str - ssdp_st: str - upnp: Mapping[str, Any] - ssdp_location: str | None = None - ssdp_nt: str | None = None - ssdp_udn: str | None = None - ssdp_ext: str | None = None - ssdp_server: str | None = None - ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - ssdp_all_locations: set[str] = field(default_factory=set) - x_homeassistant_matching_domains: set[str] = field(default_factory=set) +_DEPRECATED_SsdpServiceInfo = DeprecatedConstant( + _SsdpServiceInfo, + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + "2026.2", +) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") type SsdpHassJobCallback = HassJob[ - [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None ] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { @@ -148,7 +219,9 @@ def _format_err(name: str, *args: Any) -> str: @bind_hass async def async_register_callback( hass: HomeAssistant, - callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None], + callback: Callable[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + ], match_dict: dict[str, str] | None = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -169,7 +242,7 @@ async def async_register_callback( @bind_hass async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str -) -> SsdpServiceInfo | None: +) -> _SsdpServiceInfo | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -178,7 +251,7 @@ async def async_get_discovery_info_by_udn_st( @bind_hass async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_st(st) @@ -187,7 +260,7 @@ async def async_get_discovery_info_by_st( @bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn(udn) @@ -200,7 +273,7 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not source_ip.is_global - and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4) + and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4) } @@ -227,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _async_process_callbacks( hass: HomeAssistant, callbacks: list[SsdpHassJobCallback], - discovery_info: SsdpServiceInfo, + discovery_info: _SsdpServiceInfo, ssdp_change: SsdpChange, ) -> None: for callback in callbacks: @@ -562,11 +635,11 @@ def _ssdp_listener_process_callback( ) def _async_dismiss_discoveries( - self, byebye_discovery_info: SsdpServiceInfo + self, byebye_discovery_info: _SsdpServiceInfo ) -> None: """Dismiss all discoveries for the given address.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - SsdpServiceInfo, + _SsdpServiceInfo, lambda service_info: bool( service_info.ssdp_st == byebye_discovery_info.ssdp_st and service_info.ssdp_location == byebye_discovery_info.ssdp_location @@ -589,7 +662,7 @@ async def _async_get_description_dict( async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> SsdpServiceInfo: + ) -> _SsdpServiceInfo: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. @@ -602,7 +675,7 @@ async def _async_headers_to_discovery_info( async def async_get_discovery_info_by_udn_st( self, udn: str, st: str - ) -> SsdpServiceInfo | None: + ) -> _SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" for ssdp_device in self._ssdp_devices: if ssdp_device.udn == udn: @@ -612,7 +685,7 @@ async def async_get_discovery_info_by_udn_st( ) return None - async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -620,7 +693,7 @@ async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo] if (headers := ssdp_device.combined_headers(st)) ] - async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -665,7 +738,7 @@ def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], -) -> SsdpServiceInfo: +) -> _SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] ssdp_st = combined_headers.get_lower("st") @@ -681,11 +754,11 @@ def discovery_info_from_headers_and_description( ssdp_st = combined_headers["nt"] # Ensure UPnP "udn" is set - if ATTR_UPNP_UDN not in upnp_info: + if _ATTR_UPNP_UDN not in upnp_info: if udn := _udn_from_usn(ssdp_usn): - upnp_info[ATTR_UPNP_UDN] = udn + upnp_info[_ATTR_UPNP_UDN] = udn - return SsdpServiceInfo( + return _SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, ssdp_ext=combined_headers.get_lower("ext"), @@ -887,3 +960,11 @@ async def _async_stop_upnp_servers(self) -> None: """Stop UPnP/SSDP servers.""" for server in self._upnp_servers: await server.async_stop() + + +# 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/starline/account.py b/homeassistant/components/starline/account.py index 4b1425ae7d9707..0fb5a367148387 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -180,6 +180,7 @@ def gsm_attrs(device: StarlineDevice) -> dict[str, Any]: "online": device.online, } + # Deprecated and should be removed in 2025.8 @staticmethod def engine_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for engine switch.""" diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 69f0ae06d02ae5..ac1ad4f2b6e0b9 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -43,8 +43,13 @@ ), BinarySensorEntityDescription( key="run", - translation_key="is_running", - device_class=BinarySensorDeviceClass.RUNNING, + translation_key="ignition", + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="r_start", + translation_key="autostart", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key="hfree", diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index d7d20ae03bda71..07713a0cdfe35d 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -13,8 +13,11 @@ "moving_ban": { "default": "mdi:car-off" }, - "is_running": { - "default": "mdi:speedometer" + "ignition": { + "default": "mdi:key-variant" + }, + "autostart": { + "default": "mdi:auto-mode" } }, "button": { diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 0a30ea5b5be3d9..b3ce755778ee99 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -33,7 +33,7 @@ } }, "error": { - "error_auth_app": "Incorrect application id or secret", + "error_auth_app": "Incorrect application ID or secret", "error_auth_user": "Incorrect username or password", "error_auth_mfa": "Incorrect code" } @@ -64,8 +64,11 @@ "moving_ban": { "name": "Moving ban" }, - "is_running": { - "name": "Running" + "ignition": { + "name": "Ignition" + }, + "autostart": { + "name": "Autostart" } }, "device_tracker": { @@ -136,11 +139,11 @@ "services": { "update_state": { "name": "Update state", - "description": "Fetches the last state of the devices from the StarLine server.\n." + "description": "Fetches the last state of the devices from the StarLine server." }, "set_scan_interval": { "name": "Set scan interval", - "description": "Sets update frequency.", + "description": "Sets the update frequency for entities.", "fields": { "scan_interval": { "name": "Scan interval", @@ -150,7 +153,7 @@ }, "set_scan_obd_interval": { "name": "Set scan OBD interval", - "description": "Sets OBD info update frequency.", + "description": "Sets the update frequency for OBD information.", "fields": { "scan_interval": { "name": "Scan interval", diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 05193d98c8af39..eb71f0b73b539c 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -72,6 +72,7 @@ def available(self) -> bool: def extra_state_attributes(self): """Return the state attributes of the switch.""" if self._key == "ign": + # Deprecated and should be removed in 2025.8 return self._account.engine_attrs(self._device) return None diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index f22eafc6afdf9d..cadcba118a15b6 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -9,12 +9,12 @@ from discovery30303 import Device30303, normalize_mac import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONNECTION_EXCEPTIONS, DISCOVER_SCAN_TIMEOUT, DOMAIN @@ -41,7 +41,7 @@ def __init__(self) -> None: self._discovered_device: Device30303 | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = Device30303( diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index a5e92312f3d771..80c1dad3ee8e8a 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging +from pymodbus.client import ModbusTcpClient from pystiebeleltron import pystiebeleltron import voluptuous as vol @@ -55,13 +56,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class StiebelEltronData: """Get the latest data and update the states.""" - def __init__(self, name, modbus_client): + def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: """Init the STIEBEL ELTRON data object.""" self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update unit data.""" if not self.api.update(): _LOGGER.warning("Modbus read failed") diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 676f613f382a9c..4d302a0f70d3e7 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as STE_DOMAIN +from . import DOMAIN as STE_DOMAIN, StiebelEltronData DEPENDENCIES = ["stiebel_eltron"] @@ -81,15 +81,15 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name, ste_data): + def __init__(self, name: str, ste_data: StiebelEltronData) -> None: """Initialize the unit.""" self._name = name - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._operation = None - self._filter_alarm = None - self._force_update = False + self._target_temperature: float | int | None = None + self._current_temperature: float | int | None = None + self._current_humidity: float | int | None = None + self._operation: str | None = None + self._filter_alarm: bool | None = None + self._force_update: bool = False self._ste_data = ste_data def update(self) -> None: @@ -108,59 +108,59 @@ def update(self) -> None: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | None]: """Return device specific state attributes.""" return {"filter_alarm": self._filter_alarm} @property - def name(self): + def name(self) -> str: """Return the name of the climate device.""" return self._name # Handle ClimateEntityFeature.TARGET_TEMPERATURE @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.1 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return 10.0 @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return 30.0 @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" return float(f"{self._current_humidity:.1f}") @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return STE_TO_HA_HVAC.get(self._operation) + return STE_TO_HA_HVAC.get(self._operation) if self._operation else None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return STE_TO_HA_PRESET.get(self._operation) + return STE_TO_HA_PRESET.get(self._operation) if self._operation else None @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return SUPPORT_PRESET diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 8692a2acaadc6f..2772fc2d30e402 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -90,11 +90,11 @@ "OUTPUT_FORMATS", "RTSP_TRANSPORTS", "SOURCE_TIMEOUT", + "Orientation", "Stream", "StreamClientError", "StreamOpenClientError", "create_stream", - "Orientation", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 4184b23b9a07e1..b804055a7407c1 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -166,7 +166,7 @@ def _render_hls_template(self, last_stream_id: int, render_parts: bool) -> str: self.hls_playlist_parts.append( f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" f'"./segment/{self.sequence}.{part_num}.m4s"' - f'{",INDEPENDENT=YES" if part.has_keyframe else ""}' + f"{',INDEPENDENT=YES' if part.has_keyframe else ''}" ) if self.complete: # Construct the final playlist_template. The placeholder will share a diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 16694822b016fd..32845840f38cf0 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -188,9 +188,13 @@ def render(cls, track: HlsStreamOutput) -> str: if track.stream_settings.ll_hls: playlist.extend( [ - f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + "#EXT-X-PART-INF:PART-TARGET=" + f"{track.stream_settings.part_target_duration:.3f}", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * track.stream_settings.part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}" + ",PRECISE=YES", ] ) else: @@ -203,7 +207,9 @@ def render(cls, track: HlsStreamOutput) -> str: # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. playlist.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * track.target_duration:.3f}" + ",PRECISE=YES" ) last_stream_id = first_segment.stream_id diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index e0321e306e32d0..a2fa18c4d98e8c 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.1"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"] } diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 0c1f38938eb109..c196e57baa4b74 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -460,7 +460,7 @@ def is_valid(self, packet: av.Packet) -> bool: if packet.dts is None: if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( - f"No dts in {MAX_MISSING_DTS+1} consecutive packets" + f"No dts in {MAX_MISSING_DTS + 1} consecutive packets" ) self._missing_dts += 1 return False diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index d3c85aba1e7199..25ed29d30719b7 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -49,20 +49,20 @@ from .models import SpeechMetadata, SpeechResult __all__ = [ - "async_get_provider", - "async_get_speech_to_text_engine", - "async_get_speech_to_text_entity", + "DOMAIN", "AudioBitRates", "AudioChannels", "AudioCodecs", "AudioFormats", "AudioSampleRates", - "DOMAIN", "Provider", - "SpeechToTextEntity", "SpeechMetadata", "SpeechResult", "SpeechResultState", + "SpeechToTextEntity", + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index f39411e8afa49a..5d317ea5ba3b30 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==1.3.5"] + "requirements": ["pysuezV2==2.0.3"] } diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index b422e40ef2d2d4..3acd768cb30f34 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -135,8 +135,8 @@ def _update_attr(self, surepy_entity: SurepyEntity) -> None: self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { - "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', - "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', + "device_rssi": f"{state['signal']['device_rssi']:.2f}", + "hub_rssi": f"{state['signal']['hub_rssi']:.2f}", } else: self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 758698a7d67622..b1a716652226f9 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -114,7 +114,7 @@ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: if match := re.match( rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id ): - entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + entity_new_unique_id = f"{new_unique_id}-{match.group('id')}" _LOGGER.debug( "Migrating entity %s from %s to new id %s", entity_entry.entity_id, diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index fc2d9f491ac052..31c0c42168d415 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -67,7 +67,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: SwitchBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}' + return f"{discovery.data['modelFriendlyName']} {short_address(discovery.address)}" class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 827dce550efb63..f14547326ba320 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -43,75 +43,100 @@ class SwitchbotCloudData: devices: SwitchbotDevices -@callback -def prepare_device( +async def coordinator_for_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], -) -> tuple[Device | Remote, SwitchBotCoordinator]: +) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( device.device_id, SwitchBotCoordinator(hass, api, device) ) - return (device, coordinator) + if coordinator.data is None: + await coordinator.async_config_entry_first_refresh() -@callback -def make_device_data( + return coordinator + + +async def make_switchbot_devices( hass: HomeAssistant, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> SwitchbotDevices: - """Make device data.""" + """Make SwitchBot devices.""" devices_data = SwitchbotDevices() - for device in devices: - if isinstance(device, Remote) and device.device_type.endswith( - "Air Conditioner" - ): - devices_data.climates.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if ( - isinstance(device, Device) - and ( - device.device_type.startswith("Plug") - or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] - ) - ) or isinstance(device, Remote): - devices_data.switches.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "Meter", - "MeterPlus", - "WoIOSensor", - "Hub 2", - "MeterPro", - "MeterPro(CO2)", - "Relay Switch 1PM", - ]: - devices_data.sensors.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "K10+", - "K10+ Pro", - "Robot Vacuum Cleaner S1", - "Robot Vacuum Cleaner S1 Plus", - ]: - devices_data.vacuums.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): - devices_data.locks.append( - prepare_device(hass, api, device, coordinators_by_id) - ) + await gather( + *[ + make_device_data(hass, api, device, devices_data, coordinators_by_id) + for device in devices + ] + ) + return devices_data +async def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + device: Device | Remote, + devices_data: SwitchbotDevices, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Make device data.""" + if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.climates.append((device, coordinator)) + if ( + isinstance(device, Device) + and ( + device.device_type.startswith("Plug") + or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] + ) + ) or isinstance(device, Remote): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.switches.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Meter", + "MeterPlus", + "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", + "Relay Switch 1PM", + "Plug Mini (US)", + "Plug Mini (JP)", + ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "K10+", + "K10+ Pro", + "Robot Vacuum Cleaner S1", + "Robot Vacuum Cleaner S1 Plus", + ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.vacuums.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.locks.append((device, coordinator)) + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -129,14 +154,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} + + switchbot_devices = await make_switchbot_devices( + hass, api, devices, coordinators_by_id + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( - api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) + api=api, devices=switchbot_devices ) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather( - *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] - ) return True diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index f77adb7b1929f4..74adcb049c1f91 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -4,6 +4,7 @@ from switchbot_api import Commands, Device, Remote, SwitchBotAPI +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,3 +49,17 @@ async def send_api_command( command_type, parameters, ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._set_attributes() + super()._handle_coordinator_update() + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await super().async_added_to_hass() + self._set_attributes() diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 2fbd551b919c77..52f48c66d38dc1 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -6,7 +6,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -32,12 +32,10 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): _attr_name = None - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"] == "locked" - self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b11ca..99f909e91ab9c6 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "loggers": ["switchbot_api"], + "requirements": ["switchbot-api==2.3.1"] } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ae912e914ba2f7..1f755c141a2bbd 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -17,7 +17,7 @@ UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -61,20 +61,27 @@ native_unit_of_measurement=UnitOfPower.WATT, ) -VOLATGE_DESCRIPTION = SensorEntityDescription( +VOLTAGE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ) -CURRENT_DESCRIPTION = SensorEntityDescription( +CURRENT_DESCRIPTION_IN_MA = SensorEntityDescription( key=SENSOR_TYPE_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, ) +CURRENT_DESCRIPTION_IN_A = SensorEntityDescription( + key=SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, +) + CO2_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_CO2, device_class=SensorDeviceClass.CO2, @@ -100,8 +107,16 @@ ), "Relay Switch 1PM": ( POWER_DESCRIPTION, - VOLATGE_DESCRIPTION, - CURRENT_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + ), + "Plug Mini (US)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, + ), + "Plug Mini (JP)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, @@ -151,10 +166,8 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{device.device_id}_{description.key}" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_native_value = self.coordinator.data.get(self.entity_description.key) - self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 281ebb9322e791..0781c91bc35d6a 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -46,21 +46,18 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value - self.async_write_ha_state() class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot switch provider by a remote.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): @@ -72,13 +69,11 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot relay switch.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("switchStatus") == 1 - self.async_write_ha_state() @callback diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 2d2a1783d73e39..84db7cfdbb8a80 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -99,9 +99,8 @@ async def async_start(self) -> None: """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return @@ -111,8 +110,6 @@ def _handle_coordinator_update(self) -> None: switchbot_state = str(self.coordinator.data.get("workingStatus")) self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) - self.async_write_ha_state() - @callback def _async_make_entity( diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98950..e380711303d461 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ba0a99b408908c..7d3d71a0615a4d 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,14 +4,15 @@ from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +33,7 @@ API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -154,3 +174,91 @@ async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1fb155a5648537..1407814f8385be 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -8,10 +8,15 @@ from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -33,15 +38,15 @@ async def async_step_user( return await self._async_check_and_create("user", user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated flow.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() self.url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", ) ) @@ -52,11 +57,11 @@ async def async_step_ssdp( # Update unique id of entry with the same URL if not existing_entry.unique_id: self.hass.config_entries.async_update_entry( - existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + existing_entry, unique_id=discovery_info.upnp[ATTR_UPNP_UDN] ) return self.async_abort(reason="already_configured") - self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + self.name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") if self.name: # Remove trailing " (ip)" if present for consistency with user driven config self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 918a24035f8887..03e2eaf8e7b09e 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -18,7 +18,6 @@ ) import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -41,6 +40,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util.network import is_ip_address as is_ip @@ -243,7 +248,7 @@ async def async_step_user( return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via zeroconf.""" discovered_macs = [ @@ -258,13 +263,13 @@ async def async_step_zeroconf( return await self._async_from_discovery(host, friendly_name, discovered_macs) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via ssdp.""" parsed_url = urlparse(discovery_info.ssdp_location) - upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + upnp_friendly_name: str = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] friendly_name = upnp_friendly_name.split("(", 1)[0].strip() - mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL] discovered_macs = [format_synology_mac(mac_address)] # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index b85189715ef5b1..ab6fc20b5cb231 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.5.3"], + "requirements": ["py-synologydsm-api==2.6.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 98396e52545b1a..60b57b1e87fa14 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -16,13 +16,13 @@ from systembridgemodels.modules import GetData, Module import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_WAIT_TIMEOUT, DOMAIN @@ -179,7 +179,7 @@ async def async_step_authenticate( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 22950aa9f1ecf1..191a2b5feb8498 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -163,16 +163,16 @@ class LogEntry: """Store HA log entries.""" __slots__ = ( + "count", + "exception", "first_occurred", - "timestamp", - "name", + "key", "level", "message", - "exception", + "name", "root_cause", "source", - "count", - "key", + "timestamp", ) def __init__( diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 4a794a00432f51..2776feba2724be 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry( _LOGGER.debug("disk arguments to be added: %s", disk_arguments) coordinator: SystemMonitorCoordinator = SystemMonitorCoordinator( - hass, psutil_wrapper, disk_arguments + hass, entry, psutil_wrapper, disk_arguments ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper) @@ -60,17 +60,21 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SystemMonitorConfigEntry +) -> bool: """Unload System Monitor config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: SystemMonitorConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: SystemMonitorConfigEntry +) -> bool: """Migrate old entry.""" if entry.version > 1: diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 34b28a1d47a0ce..4be31f6944caa7 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -8,8 +8,6 @@ import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.config_entries import ConfigFlowResult -from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -100,12 +98,3 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return "System Monitor" - - @callback - def async_create_entry( - self, data: Mapping[str, Any], **kwargs: Any - ) -> ConfigFlowResult: - """Finish config flow and create a config entry.""" - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - return super().async_create_entry(data, **kwargs) diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 32a171a11caca8..03b769ee2e2319 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime import logging import os -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from psutil import Process from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap @@ -17,6 +17,9 @@ from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import SystemMonitorConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -83,6 +86,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): def __init__( self, hass: HomeAssistant, + config_entry: SystemMonitorConfigEntry, psutil_wrapper: ha_psutil.PsutilWrapper, arguments: list[str], ) -> None: @@ -90,6 +94,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=config_entry, name="System Monitor update coordinator", update_interval=DEFAULT_SCAN_INTERVAL, always_update=False, diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 21b9798ef466aa..bd16464b2906f7 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"] + "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"], + "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index e595e6288532b9..fb8a318ff453bc 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "single_instance_allowed": "[%key:common::config_flow::abort::already_configured_service%]" }, "step": { "user": { diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index c7bb76849010da..f251a292800b68 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -10,7 +10,6 @@ import requests.exceptions import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_FALLBACK, @@ -49,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, tado = await hass.async_add_executor_job( Tado, data[CONF_USERNAME], data[CONF_PASSWORD] ) - tado_me = await hass.async_add_executor_job(tado.getMe) + tado_me = await hass.async_add_executor_job(tado.get_me) except KeyError as ex: raise InvalidAuth from ex except RuntimeError as ex: @@ -104,14 +107,14 @@ async def async_step_user( ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { key.lower(): value for (key, value) in discovery_info.properties.items() } - await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID]) + await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 48fe2d23727de4..daf0fbd32b7bb7 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,8 +15,6 @@ ) import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow @@ -27,6 +25,8 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -83,7 +83,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Tailwind device.""" if not (device_id := discovery_info.properties.get("device_id")): diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index ec13dc7bd1f64f..dafb46e6f631dc 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -58,7 +58,7 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), - name=f"Door {coordinator.data.doors[door_id].index+1}", + name=f"Door {coordinator.data.doors[door_id].index + 1}", manufacturer="Tailwind", model=coordinator.data.product, sw_version=coordinator.data.firmware_version, diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 9deb846f8e21a7..5b1adc839aca00 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -66,8 +66,7 @@ async def async_step_config( if user_input is not None: bad_prefix = False prefix = user_input[CONF_DISCOVERY_PREFIX] - if prefix.endswith("/#"): - prefix = prefix[:-2] + prefix = prefix.removesuffix("/#") try: valid_subscribe_topic(f"{prefix}/#") except vol.Invalid: diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index 1ecefe6f85c6db..f231e206c961a9 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -4,28 +4,19 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from technove import Station as TechnoVEStation from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import TechnoVEConfigEntry -from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity @@ -34,7 +25,6 @@ class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): """Describes TechnoVE binary sensor entity.""" - deprecated_version: str | None = None value_fn: Callable[[TechnoVEStation], bool | None] @@ -57,15 +47,6 @@ class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda station: station.info.is_battery_protected, ), - TechnoVEBinarySensorDescription( - key="is_session_active", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - value_fn=lambda station: station.info.is_session_active, - deprecated_version="2025.2.0", - # Disabled by default, as this entity is deprecated - entity_registry_enabled_default=False, - ), TechnoVEBinarySensorDescription( key="is_static_ip", translation_key="is_static_ip", @@ -113,34 +94,3 @@ def is_on(self) -> bool | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) - - async def async_added_to_hass(self) -> None: - """Raise issue when entity is registered and was not disabled.""" - if TYPE_CHECKING: - assert self.unique_id - if entity_id := er.async_get(self.hass).async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id - ): - if self.enabled and self.entity_description.deprecated_version: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - breaks_in_ha_version=self.entity_description.deprecated_version, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_entity_{self.entity_description.key}", - translation_placeholders={ - "sensor_name": self.name - if isinstance(self.name, str) - else entity_id, - "entity": entity_id, - }, - ) - else: - async_delete_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 0e4f026ba5c994..7ad9829b631033 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -5,10 +5,11 @@ from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -49,7 +50,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the device with provided mac is already configured diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 7175b7c2de500d..9976f0b3c59b95 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -90,11 +90,5 @@ "set_charging_enabled_on_auto_charge": { "message": "Cannot enable or disable charging when auto-charge is enabled. Try disabling auto-charge first." } - }, - "issues": { - "deprecated_entity_is_session_active": { - "title": "The TechnoVE {sensor_name} binary sensor is deprecated", - "description": "`{entity}` is deprecated.\nPlease update your automations and scripts to replace the binary sensor entity with the newly added switch entity.\nWhen you are done migrating you can disable `{entity}`." - } } } diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 94d3f0b6831352..4f167619f0494d 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -69,20 +69,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 4012b6d07c5bca..f9ebb29dd04635 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -60,7 +60,7 @@ def __init__(self, hass: HomeAssistant, entry: TedeeConfigEntry) -> None: self._next_get_locks = time.time() self._locks_last_update: set[int] = set() - self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.new_lock_callbacks: list[Callable[[list[TedeeLock]], None]] = [] self.tedee_webhook_id: int | None = None async def _async_setup(self) -> None: @@ -158,8 +158,7 @@ def _async_add_remove_locks(self) -> None: # add new locks if new_locks := current_locks - self._locks_last_update: _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) - for lock_id in new_locks: - for callback in self.new_lock_callbacks: - callback(lock_id) + for callback in self.new_lock_callbacks: + callback([self.data[lock_id] for lock_id in new_locks]) self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 38df85a9cdb7d5..482cd039a98568 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -24,23 +24,18 @@ async def async_setup_entry( """Set up the Tedee lock entity.""" coordinator = entry.runtime_data - entities: list[TedeeLockEntity] = [] - for lock in coordinator.data.values(): - if lock.is_enabled_pullspring: - entities.append(TedeeLockWithLatchEntity(lock, coordinator)) - else: - entities.append(TedeeLockEntity(lock, coordinator)) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] - if lock.is_enabled_pullspring: - async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) - else: - async_add_entities([TedeeLockEntity(lock, coordinator)]) + def _async_add_new_lock(locks: list[TedeeLock]) -> None: + entities: list[TedeeLockEntity] = [] + for lock in locks: + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + async_add_entities(entities) coordinator.new_lock_callbacks.append(_async_add_new_lock) - async_add_entities(entities) + _async_add_new_lock(list(coordinator.data.values())) class TedeeLockEntity(TedeeEntity, LockEntity): diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index d61e7360dc4ff8..828793b4458bac 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -58,20 +58,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeSensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 78cacd706d3122..c7204b6d2a912f 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup your tedee locks", + "title": "Set up your tedee locks", "data": { "local_access_token": "Local access token", "host": "[%key:common::config_flow::data::host%]" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "title": "Update of access key required", - "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "description": "Tedee needs an updated access key because the existing one is invalid or might have expired.", "data": { "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" }, @@ -23,7 +23,7 @@ } }, "reconfigure": { - "title": "Reconfigure Tedee", + "title": "Reconfigure tedee", "description": "Update the settings of this integration.", "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b9a032d7f28404..f744265e1c2cad 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -36,7 +36,13 @@ HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration @@ -398,15 +404,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, bot, p_config.get(CONF_ALLOWED_CHAT_IDS), p_config.get(ATTR_PARSER) ) - async def async_send_telegram_message(service: ServiceCall) -> None: + async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: """Handle sending Telegram Bot message service calls.""" msgtype = service.service kwargs = dict(service.data) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) + messages = None if msgtype == SERVICE_SEND_MESSAGE: - await notify_service.send_message(context=service.context, **kwargs) + messages = await notify_service.send_message( + context=service.context, **kwargs + ) elif msgtype in [ SERVICE_SEND_PHOTO, SERVICE_SEND_ANIMATION, @@ -414,13 +423,19 @@ async def async_send_telegram_message(service: ServiceCall) -> None: SERVICE_SEND_VOICE, SERVICE_SEND_DOCUMENT, ]: - await notify_service.send_file(msgtype, context=service.context, **kwargs) + messages = await notify_service.send_file( + msgtype, context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_STICKER: - await notify_service.send_sticker(context=service.context, **kwargs) + messages = await notify_service.send_sticker( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_LOCATION: - await notify_service.send_location(context=service.context, **kwargs) + messages = await notify_service.send_location( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_POLL: - await notify_service.send_poll(context=service.context, **kwargs) + messages = await notify_service.send_poll(context=service.context, **kwargs) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await notify_service.answer_callback_query( context=service.context, **kwargs @@ -432,10 +447,37 @@ async def async_send_telegram_message(service: ServiceCall) -> None: msgtype, context=service.context, **kwargs ) + if service.return_response and messages: + return { + "chats": [ + {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() + ] + } + return None + # Register notification services for service_notif, schema in SERVICE_MAP.items(): + supports_response = SupportsResponse.NONE + + if service_notif in [ + SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_STICKER, + SERVICE_SEND_LOCATION, + SERVICE_SEND_POLL, + ]: + supports_response = SupportsResponse.OPTIONAL + hass.services.async_register( - DOMAIN, service_notif, async_send_telegram_message, schema=schema + DOMAIN, + service_notif, + async_send_telegram_message, + schema=schema, + supports_response=supports_response, ) return True @@ -694,9 +736,10 @@ async def send_message(self, message="", target=None, context=None, **kwargs): title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) - await self._send_msg( + msg = await self._send_msg( self.bot.send_message, "Error sending message", params[ATTR_MESSAGE_TAG], @@ -711,6 +754,8 @@ async def send_message(self, message="", target=None, context=None, **kwargs): message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): """Delete a previously sent message.""" @@ -829,12 +874,13 @@ async def send_file( ), ) + msg_ids = {} if file_content: for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_photo, "Error sending photo", params[ATTR_MESSAGE_TAG], @@ -851,7 +897,7 @@ async def send_file( ) elif file_type == SERVICE_SEND_STICKER: - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -866,7 +912,7 @@ async def send_file( ) elif file_type == SERVICE_SEND_VIDEO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_video, "Error sending video", params[ATTR_MESSAGE_TAG], @@ -882,7 +928,7 @@ async def send_file( context=context, ) elif file_type == SERVICE_SEND_DOCUMENT: - await self._send_msg( + msg = await self._send_msg( self.bot.send_document, "Error sending document", params[ATTR_MESSAGE_TAG], @@ -898,7 +944,7 @@ async def send_file( context=context, ) elif file_type == SERVICE_SEND_VOICE: - await self._send_msg( + msg = await self._send_msg( self.bot.send_voice, "Error sending voice", params[ATTR_MESSAGE_TAG], @@ -913,7 +959,7 @@ async def send_file( context=context, ) elif file_type == SERVICE_SEND_ANIMATION: - await self._send_msg( + msg = await self._send_msg( self.bot.send_animation, "Error sending animation", params[ATTR_MESSAGE_TAG], @@ -929,17 +975,22 @@ async def send_file( context=context, ) + msg_ids[chat_id] = msg.id file_content.seek(0) else: _LOGGER.error("Can't send file with kwargs: %s", kwargs) - async def send_sticker(self, target=None, context=None, **kwargs): + return msg_ids + + async def send_sticker(self, target=None, context=None, **kwargs) -> dict: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) + + msg_ids = {} if stickerid: for chat_id in self._get_target_chat_ids(target): - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -952,8 +1003,9 @@ async def send_sticker(self, target=None, context=None, **kwargs): message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - else: - await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + msg_ids[chat_id] = msg.id + return msg_ids + return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) async def send_location( self, latitude, longitude, target=None, context=None, **kwargs @@ -962,11 +1014,12 @@ async def send_location( latitude = float(latitude) longitude = float(longitude) params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) - await self._send_msg( + msg = await self._send_msg( self.bot.send_location, "Error sending location", params[ATTR_MESSAGE_TAG], @@ -979,6 +1032,8 @@ async def send_location( message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def send_poll( self, @@ -993,9 +1048,10 @@ async def send_poll( """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) - await self._send_msg( + msg = await self._send_msg( self.bot.send_poll, "Error sending poll", params[ATTR_MESSAGE_TAG], @@ -1011,6 +1067,8 @@ async def send_poll( message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def leave_chat(self, chat_id=None, context=None): """Remove bot from chat.""" @@ -1070,6 +1128,7 @@ def _get_message_event_data(self, message: Message) -> tuple[str, dict[str, Any] ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, ATTR_DATE: message.date, + ATTR_MESSAGE_THREAD_ID: message.message_thread_id, } if filters.COMMAND.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 1a02543d4abfbf..714e7b74db00a4 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -14,7 +14,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "Parse mode", @@ -30,7 +30,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send message. Will help with timeout errors (poor internet connection, etc)s." + "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc)." }, "keyboard": { "name": "Keyboard", @@ -45,11 +45,11 @@ "description": "Tag for sent message." }, "reply_to_message_id": { - "name": "Reply to message id", + "name": "Reply to message ID", "description": "Mark the message as a reply to a previous message." }, "message_thread_id": { - "name": "Message thread id", + "name": "Message thread ID", "description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only." } } @@ -84,7 +84,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", @@ -100,7 +100,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send photo." + "description": "Timeout for sending the photo in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -166,7 +166,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send sticker." + "description": "Timeout for sending the sticker in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -306,7 +306,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send video." + "description": "Timeout for sending the video in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -372,7 +372,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send voice." + "description": "Timeout for sending the voice in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -442,7 +442,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send document." + "description": "Timeout for sending the document in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -480,7 +480,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default." }, "disable_notification": { "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", @@ -546,7 +546,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send poll." + "description": "Timeout for sending the poll in seconds." }, "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", @@ -568,11 +568,11 @@ "fields": { "message_id": { "name": "Message ID", - "description": "Id of the message to edit." + "description": "ID of the message to edit." }, "chat_id": { "name": "Chat ID", - "description": "The chat_id where to edit the message." + "description": "ID of the chat where to edit the message." }, "message": { "name": "Message", @@ -606,7 +606,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the caption." + "description": "ID of the chat where to edit the caption." }, "caption": { "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", @@ -620,7 +620,7 @@ }, "edit_replymarkup": { "name": "Edit reply markup", - "description": "Edit the inline keyboard of a previously sent message.", + "description": "Edits the inline keyboard of a previously sent message.", "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", @@ -628,7 +628,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the reply_markup." + "description": "ID of the chat where to edit the reply markup." }, "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", @@ -646,7 +646,7 @@ }, "callback_query_id": { "name": "Callback query ID", - "description": "Unique id of the callback response." + "description": "Unique ID of the callback response." }, "show_alert": { "name": "Show alert", @@ -654,7 +654,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for sending the answer." + "description": "Timeout for sending the answer in seconds." } } }, @@ -664,11 +664,11 @@ "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", - "description": "Id of the message to delete." + "description": "ID of the message to delete." }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to delete the message." + "description": "ID of the chat where to delete the message." } } } diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index e363aced667074..b0750a7785d98a 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,15 +11,15 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate against TelldusLive" + "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", + "title": "Authenticate with TelldusLive" }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for local API." } } } diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 82d8905a775664..0178a6521c4008 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -4,9 +4,9 @@ from datetime import timedelta import logging -import telnetlib # pylint: disable=deprecated-module from typing import Any +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 390a4a31bdb7db..7b7b5eb9b29864 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue if isinstance(entry.options[key], str): raise ConfigEntryError( - f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to " + f"The '{entry.options.get(CONF_NAME) or ''}' number template needs to " f"be reconfigured, {key} must be a number, got '{entry.options[key]}'" ) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1cd856f31d024b..81705e326f762d 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.2.1", + "numpy==2.2.2", "Pillow==11.1.0" ] } diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index ff50a99748ea4d..945c6351cfc56d 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -36,6 +36,7 @@ from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -176,9 +177,11 @@ async def _refresh_token() -> str: api = EnergySpecific(tesla.energy, site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api) + history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(hass, api) info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product) await live_coordinator.async_config_entry_first_refresh() + await history_coordinator.async_config_entry_first_refresh() await info_coordinator.async_config_entry_first_refresh() # Create energy site model @@ -211,6 +214,7 @@ async def _refresh_token() -> str: TeslaFleetEnergyData( api=api, live_coordinator=live_coordinator, + history_coordinator=history_coordinator, info_coordinator=info_coordinator, id=site_id, device=device, diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 9b3baf49bfb741..5d2dc84c49e953 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -37,6 +37,30 @@ "T": "Tesla Semi", } +ENERGY_HISTORY_FIELDS = [ + "solar_energy_exported", + "generator_energy_exported", + "grid_energy_imported", + "grid_services_energy_imported", + "grid_services_energy_exported", + "grid_energy_exported_from_solar", + "grid_energy_exported_from_generator", + "grid_energy_exported_from_battery", + "battery_energy_exported", + "battery_energy_imported_from_grid", + "battery_energy_imported_from_solar", + "battery_energy_imported_from_generator", + "consumer_energy_imported_from_grid", + "consumer_energy_imported_from_solar", + "consumer_energy_imported_from_battery", + "consumer_energy_imported_from_generator", + "total_home_usage", + "total_battery_charge", + "total_battery_discharge", + "total_solar_generation", + "total_grid_energy_exported", +] + class TeslaFleetState(StrEnum): """Teslemetry Vehicle States.""" diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 42b93352a6fb85..4d99319d49f85b 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,10 +1,12 @@ """Tesla Fleet Data Coordinator.""" from datetime import datetime, timedelta +from random import randint +from time import time from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific -from tesla_fleet_api.const import VehicleDataEndpoint +from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, LoginRequired, @@ -19,14 +21,15 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER, TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 90 +VEHICLE_INTERVAL_SECONDS = 300 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_INTERVAL_SECONDS = 60 ENERGY_INTERVAL = timedelta(seconds=ENERGY_INTERVAL_SECONDS) +ENERGY_HISTORY_INTERVAL = timedelta(minutes=5) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -73,7 +76,7 @@ def __init__( self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(200, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) + self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" @@ -182,6 +185,61 @@ async def _async_update_data(self) -> dict[str, Any]: return data +class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Tesla Fleet Energy Site History coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"Tesla Fleet Energy History {api.energy_site_id}", + update_interval=timedelta(seconds=300), + ) + self.api = api + self.data = {} + self.updated_once = False + + async def async_config_entry_first_refresh(self) -> None: + """Set up the data coordinator.""" + await super().async_config_entry_first_refresh() + + # Calculate seconds until next 5 minute period plus a random delay + delta = randint(310, 330) - (int(time()) % 300) + self.logger.debug("Scheduling next %s refresh in %s seconds", self.name, delta) + self.update_interval = timedelta(seconds=delta) + self._schedule_refresh() + self.update_interval = ENERGY_HISTORY_INTERVAL + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site history data using Tesla Fleet API.""" + + try: + data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] + except RateLimited as e: + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + self.name, + e.data.get("after"), + ) + if "after" in e.data: + self.update_interval = timedelta(seconds=int(e.data["after"])) + return self.data + except (InvalidToken, OAuthExpired, LoginRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + self.updated_once = True + + # Add all time periods together + output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + for period in data.get("time_series", []): + for key in ENERGY_HISTORY_FIELDS: + output[key] += period.get(key, 0) + + return output + + class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the TeslaFleet API.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0ee41b5e32271e..0260acf368e2df 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -12,6 +12,7 @@ from .const import DOMAIN from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -24,6 +25,7 @@ class TeslaFleetEntity( CoordinatorEntity[ TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator ] ): @@ -37,6 +39,7 @@ def __init__( self, coordinator: TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, api: VehicleSpecific | EnergySpecific, key: str, @@ -139,6 +142,21 @@ def __init__( super().__init__(data.live_coordinator, data.api, key) +class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity): + """Parent class for TeslaFleet Energy Site History entities.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Tesla Fleet Energy Site History entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.history_coordinator, data.api, key) + + class TeslaFleetEnergyInfoEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Info entities.""" diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 449dda93c62a8d..c806138c219335 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -232,6 +232,69 @@ "island_status_unknown": "mdi:help-circle", "off_grid_intentional": "mdi:account-cancel" } + }, + "total_home_usage": { + "default": "mdi:home-lightning-bolt" + }, + "total_battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "total_battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "total_solar_production": { + "default": "mdi:solar-power-variant" + }, + "grid_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "total_grid_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "solar_energy_exported": { + "default": "mdi:solar-power-variant" + }, + "generator_energy_exported": { + "default": "mdi:generator-stationary" + }, + "grid_services_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "grid_services_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "grid_energy_exported_from_solar": { + "default": "mdi:solar-power" + }, + "grid_energy_exported_from_generator": { + "default": "mdi:generator-stationary" + }, + "grid_energy_exported_from_battery": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_exported": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "battery_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "battery_energy_imported_from_generator": { + "default": "mdi:generator-stationary" + }, + "consumer_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "consumer_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "consumer_energy_imported_from_battery": { + "default": "mdi:home-battery" + }, + "consumer_energy_imported_from_generator": { + "default": "mdi:generator-stationary" } }, "switch": { diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index ae945dd96bf1c3..469ebdca91442c 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -11,6 +11,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -44,6 +45,7 @@ class TeslaFleetEnergyData: api: EnergySpecific live_coordinator: TeslaFleetEnergySiteLiveCoordinator + history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index b4e7b51faba781..3e05e7e723b19b 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -35,8 +35,9 @@ from homeassistant.util.variance import ignore_variance from . import TeslaFleetConfigEntry -from .const import TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, TeslaFleetState from .entity import ( + TeslaFleetEnergyHistoryEntity, TeslaFleetEnergyInfoEntity, TeslaFleetEnergyLiveEntity, TeslaFleetVehicleEntity, @@ -415,6 +416,21 @@ class TeslaFleetTimeEntityDescription(SensorEntityDescription): ), ) +ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( + SensorEntityDescription( + key=key, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=( + key.startswith("total") or key == "grid_energy_imported" + ), + ) + for key in ENERGY_HISTORY_FIELDS +) + ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="vpp_backup_reserve_percent", @@ -450,6 +466,13 @@ async def async_setup_entry( for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data ), + ( # Add energy site history + TeslaFleetEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + or energysite.info_coordinator.data.get("components_solar") + ), ( # Add wall connectors TeslaFleetWallConnectorSensorEntity(energysite, wc["din"], description) for energysite in entry.runtime_data.energysites @@ -540,7 +563,25 @@ def __init__( def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = not self.is_none + self._attr_native_value = self._value + + +class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): + """Base class for Tesla Fleet energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + data: TeslaFleetEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" self._attr_native_value = self._value diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fe5cd06c1ef476..c438bfff50f715 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -424,6 +424,9 @@ "off_grid_intentional": "Disconnected intentionally" } }, + "storm_mode_active": { + "name": "Storm Watch active" + }, "vehicle_state_tpms_pressure_fl": { "name": "Tire pressure front left" }, @@ -453,6 +456,69 @@ }, "wall_connector_state": { "name": "State code" + }, + "solar_energy_exported": { + "name": "Solar exported" + }, + "generator_energy_exported": { + "name": "Generator exported" + }, + "grid_energy_imported": { + "name": "Grid imported" + }, + "grid_services_energy_imported": { + "name": "Grid services imported" + }, + "grid_services_energy_exported": { + "name": "Grid services exported" + }, + "grid_energy_exported_from_solar": { + "name": "Grid exported from solar" + }, + "grid_energy_exported_from_generator": { + "name": "Grid exported from generator" + }, + "grid_energy_exported_from_battery": { + "name": "Grid exported from battery" + }, + "battery_energy_exported": { + "name": "Battery exported" + }, + "battery_energy_imported_from_grid": { + "name": "Battery imported from grid" + }, + "battery_energy_imported_from_solar": { + "name": "Battery imported from solar" + }, + "battery_energy_imported_from_generator": { + "name": "Battery imported from generator" + }, + "consumer_energy_imported_from_grid": { + "name": "Consumer imported from grid" + }, + "consumer_energy_imported_from_solar": { + "name": "Consumer imported from solar" + }, + "consumer_energy_imported_from_battery": { + "name": "Consumer imported from battery" + }, + "consumer_energy_imported_from_generator": { + "name": "Consumer imported from generator" + }, + "total_home_usage": { + "name": "Home usage" + }, + "total_battery_charge": { + "name": "Battery charged" + }, + "total_battery_discharge": { + "name": "Battery discharged" + }, + "total_solar_generation": { + "name": "Solar generated" + }, + "total_grid_energy_exported": { + "name": "Grid exported" } }, "switch": { diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index 3296539f701ef0..d100b1e554974d 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -9,11 +9,11 @@ from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER @@ -48,7 +48,7 @@ def __init__(self) -> None: self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 5779283b955cee..b9cbc64dcd97d3 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -7,6 +7,7 @@ from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( + Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, @@ -126,13 +127,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, ) + firmware = vehicle_metadata[vin].get("firmware", "Unknown") + stream_vehicle = stream.get_vehicle(vin) vehicles.append( TeslemetryVehicleData( api=api, + config_entry=entry, coordinator=coordinator, stream=stream, + stream_vehicle=stream_vehicle, vin=vin, + firmware=firmware, device=device, remove_listener=remove_listener, ) @@ -160,10 +166,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - serial_number=str(site_id), ) + # Check live status endpoint works before creating its coordinator + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TeslemetryEnergyData( api=api, - live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api), + live_coordinator=( + TeslemetryEnergySiteLiveCoordinator(hass, api, live_status) + if isinstance(live_status, dict) + else None + ), info_coordinator=TeslemetryEnergySiteInfoCoordinator( hass, api, product ), @@ -179,14 +197,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Run all first refreshes await asyncio.gather( + *(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles), *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles ), - *( - energysite.live_coordinator.async_config_entry_first_refresh() - for energysite in energysites - ), *( energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites @@ -265,3 +280,16 @@ def handle_vehicle_stream(data: dict) -> None: coordinator.async_set_updated_data(coordinator.data) return handle_vehicle_stream + + +async def async_setup_stream( + hass: HomeAssistant, entry: ConfigEntry, vehicle: TeslemetryVehicleData +): + """Set up the stream for a vehicle.""" + + await vehicle.stream_vehicle.get_config() + entry.async_create_background_task( + hass, + vehicle.stream_vehicle.prefer_typed(True), + f"Prefer typed for {vehicle.vin}", + ) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 29ebfea4db110e..0b6823f8b61c4c 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,17 +4,20 @@ from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import cast +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry @@ -23,6 +26,7 @@ TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,133 +37,327 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Teslemetry binary sensor entity.""" - is_on: Callable[[StateType], bool] = bool + polling_value_fn: Callable[[StateType], bool | None] = bool + polling: bool = False + streaming_key: Signal | None = None + streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[StateType], bool | None] = ( + lambda x: x is True or x == "true" + ) VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="state", + polling=True, + polling_value_fn=lambda x: x == TeslemetryState.ONLINE, device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on=lambda x: x == TeslemetryState.ONLINE, ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", + polling=True, + streaming_key=Signal.BATTERY_HEATER_ON, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", - is_on=lambda x: cast(int, x) > 1, + polling=True, + streaming_key=Signal.CHARGER_PHASES, + polling_value_fn=lambda x: cast(int, x) > 1, + streaming_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", + polling=True, + streaming_key=Signal.PRECONDITIONING_ENABLED, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="climate_state_is_preconditioning", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", + polling=True, + streaming_key=Signal.SCHEDULED_CHARGING_PENDING, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_trip_charging", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_conn_charge_cable", - is_on=lambda x: x != "", + polling=True, + polling_value_fn=lambda x: x != "", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="climate_state_cabin_overheat_protection_actively_cooling", + polling=True, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", + polling=True, device_class=BinarySensorDeviceClass.RUNNING, - is_on=lambda x: x == "Recording", + polling_value_fn=lambda x: x == "Recording", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_is_user_present", + polling=True, device_class=BinarySensorDeviceClass.PRESENCE, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", + polling=True, + streaming_key=Signal.FD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", + polling=True, + streaming_key=Signal.FP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", + polling=True, + streaming_key=Signal.RD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", + polling=True, + streaming_key=Signal.RP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_df", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"), entity_category=EntityCategory.DIAGNOSTIC, ), + TeslemetryBinarySensorEntityDescription( + key="automatic_blind_spot_camera", + streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="automatic_emergency_braking_off", + streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="blind_spot_collision_warning_chime", + streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="bms_full_charge_complete", + streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="brake_pedal", + streaming_key=Signal.BRAKE_PEDAL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_port_cold_weather_mode", + streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="service_mode", + streaming_key=Signal.SERVICE_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="pin_to_drive_enabled", + streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="drive_rail", + streaming_key=Signal.DRIVE_RAIL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_belt", + streaming_key=Signal.DRIVER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_occupied", + streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="passenger_seat_belt", + streaming_key=Signal.PASSENGER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="fast_charger_present", + streaming_key=Signal.FAST_CHARGER_PRESENT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="gps_state", + streaming_key=Signal.GPS_STATE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + TeslemetryBinarySensorEntityDescription( + key="guest_mode_enabled", + streaming_key=Signal.GUEST_MODE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="dc_dc_enable", + streaming_key=Signal.DC_DC_ENABLE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="emergency_lane_departure_avoidance", + streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="supercharger_session_trip_planner", + streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="wiper_heat_enabled", + streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="rear_display_hvac_enabled", + streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="offroad_lightbar_present", + streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="homelink_nearby", + streaming_key=Signal.HOMELINK_NEARBY, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="europe_vehicle", + streaming_key=Signal.EUROPE_VEHICLE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="right_hand_drive", + streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_home", + streaming_key=Signal.LOCATED_AT_HOME, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_work", + streaming_key=Signal.LOCATED_AT_WORK, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_favorite", + streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_firmware="2024.44.32", + entity_registry_enabled_default=False, + ), ) ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( @@ -183,30 +381,42 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry binary sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Vehicles - TeslemetryVehicleBinarySensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Energy Site Live - TeslemetryEnergyLiveBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ( # Energy Site Info - TeslemetryEnergyInfoBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ) + entities: list[BinarySensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append( + TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) + ) + elif description.polling: + entities.append( + TeslemetryVehiclePollingBinarySensorEntity(vehicle, description) + ) + + entities.extend( + TeslemetryEnergyLiveBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data ) + entities.extend( + TeslemetryEnergyInfoBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ) + + async_add_entities(entities) -class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity): +class TeslemetryVehiclePollingBinarySensorEntity( + TeslemetryVehicleEntity, BinarySensorEntity +): """Base class for Teslemetry vehicle binary sensors.""" entity_description: TeslemetryBinarySensorEntityDescription @@ -223,12 +433,40 @@ def __init__( def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - if self._value is None: - self._attr_available = False - self._attr_is_on = None - else: - self._attr_available = True - self._attr_is_on = self.entity_description.is_on(self._value) + self._attr_available = self._value is not None + if self._attr_available: + assert self._value is not None + self._attr_is_on = self.entity_description.polling_value_fn(self._value) + + +class TeslemetryVehicleStreamingBinarySensorEntity( + TeslemetryVehicleStreamEntity, BinarySensorEntity, RestoreEntity +): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_is_on = state.state == STATE_ON + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + self._attr_available = value is not None + if self._attr_available: + self._attr_is_on = self.entity_description.streaming_value_fn(value) class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index a9bf3eddd6a95d..ecdcd016221eed 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -24,11 +24,11 @@ class TeslemetryButtonEntityDescription(ButtonEntityDescription): """Describes a Teslemetry Button entity.""" - func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( - TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup + TeslemetryButtonEntityDescription(key="wake", func=lambda self: self.api.wake_up()), TeslemetryButtonEntityDescription( key="flash_lights", func=lambda self: self.api.flash_lights() ), @@ -85,6 +85,4 @@ def _async_update_attrs(self) -> None: async def async_press(self) -> None: """Press the button.""" - await self.wake_up_if_asleep() - if self.entity_description.func: - await handle_vehicle_command(self.entity_description.func(self)) + await handle_vehicle_command(self.entity_description.func(self)) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 303a3250edf386..d39402c622ccb4 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -69,7 +69,9 @@ async def _async_update_data(self) -> dict[str, Any]: class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + updated_once: bool + + def __init__(self, hass: HomeAssistant, api: EnergySpecific, data: dict) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( hass, @@ -79,6 +81,12 @@ def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index d14ef385b9cae1..4cc15b6feb8af4 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -2,9 +2,12 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState from homeassistant.components.cover import ( CoverDeviceClass, @@ -13,9 +16,14 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -33,51 +41,69 @@ async def async_setup_entry( """Set up the Teslemetry cover platform from a config entry.""" async_add_entities( - klass(vehicle, entry.runtime_data.scopes) - for (klass) in ( - TeslemetryWindowEntity, - TeslemetryChargePortEntity, - TeslemetryFrontTrunkEntity, - TeslemetryRearTrunkEntity, - TeslemetrySunroofEntity, + chain( + ( + TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the windows.""" +class CoverRestoreEntity(RestoreEntity, CoverEntity): + """Restore class for cover entities.""" - _attr_device_class = CoverDeviceClass.WINDOW + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + if state.state == "open": + self._attr_is_closed = False + elif state.state == "closed": + self._attr_is_closed = True - def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the cover.""" - super().__init__(data, "windows") - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) - if not self.scoped: - self._attr_supported_features = CoverEntityFeature(0) - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - fd = self.get("vehicle_state_fd_window") - fp = self.get("vehicle_state_fp_window") - rd = self.get("vehicle_state_rd_window") - rp = self.get("vehicle_state_rp_window") +class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): + """Base class for window cover entities.""" - # Any open set to open - if OPEN in (fd, fp, rd, rp): - self._attr_is_closed = False - # All closed set to closed - elif CLOSED == fd == fp == rd == rp: - self._attr_is_closed = True + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE async def async_open_cover(self, **kwargs: Any) -> None: """Vent windows.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command( self.api.window_control(command=WindowCommand.VENT) ) @@ -87,7 +113,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Close windows.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command( self.api.window_control(command=WindowCommand.CLOSE) ) @@ -95,32 +121,110 @@ async def async_close_cover(self, **kwargs: Any) -> None: self.async_write_ha_state() -class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingWindowEntity( + TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +): + """Polling cover entity for windows.""" - def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "charge_state_charge_port_door_open") - self.scoped = any( - scope in scopes - for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) - ) - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) + super().__init__(data, "windows") + self.scoped = Scope.VEHICLE_CMDS in scopes if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = not self._value + fd = self.get("vehicle_state_fd_window") + fp = self.get("vehicle_state_fp_window") + rd = self.get("vehicle_state_rd_window") + rp = self.get("vehicle_state_rp_window") + + if OPEN in (fd, fp, rd, rp): + self._attr_is_closed = False + elif None in (fd, fp, rd, rp): + self._attr_is_closed = None + else: + self._attr_is_closed = True + + +class TeslemetryStreamingWindowEntity( + TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity +): + """Streaming cover entity for windows.""" + + fd: bool | None = None + fp: bool | None = None + rd: bool | None = None + rp: bool | None = None + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__( + data, + "windows", + ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + for signal in ( + Signal.FD_WINDOW, + Signal.FP_WINDOW, + Signal.RD_WINDOW, + Signal.RP_WINDOW, + ): + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(signal), + f"Adding field {signal} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data) -> None: + """Update the entity attributes.""" + + if value := data.get(Signal.FD_WINDOW): + self.fd = WindowState.get(value) == "closed" + if value := data.get(Signal.FP_WINDOW): + self.fp = WindowState.get(value) == "closed" + if value := data.get(Signal.RD_WINDOW): + self.rd = WindowState.get(value) == "closed" + if value := data.get(Signal.RP_WINDOW): + self.rp = WindowState.get(value) == "closed" + + if False in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = False + elif None in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = None + else: + self._attr_is_closed = True + + self.async_write_ha_state() + + +class TeslemetryChargePortEntity( + TeslemetryRootEntity, + CoverEntity, +): + """Base class for for charge port cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE async def async_open_cover(self, **kwargs: Any) -> None: """Open charge port.""" self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_closed = False self.async_write_ha_state() @@ -128,64 +232,141 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Close charge port.""" self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_close()) self._attr_is_closed = True self.async_write_ha_state() -class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the front trunk.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingChargePortEntity( + TeslemetryVehicleEntity, TeslemetryChargePortEntity +): + """Polling cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_ft") - - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = CoverEntityFeature.OPEN + super().__init__(vehicle, "charge_state_charge_port_door_open") + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + self._attr_is_closed = not self._value + + +class TeslemetryStreamingChargePortEntity( + TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity, CoverRestoreEntity +): + """Streaming cover entity for the charge port.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__( + vehicle, + "charge_state_charge_port_door_open", + ) + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortDoorOpen( + self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Base class for the front trunk cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN async def async_open_cover(self, **kwargs: Any) -> None: """Open front trunk.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) self._attr_is_closed = False self.async_write_ha_state() + # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the rear trunk.""" - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingFrontTrunkEntity( + TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +): + """Polling cover entity for the front trunk.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_rt") - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_ft") def _async_update_attrs(self) -> None: """Update the entity attributes.""" self._attr_is_closed = self._value == CLOSED + +class TeslemetryStreamingFrontTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryFrontTrunkEntity, CoverRestoreEntity +): + """Streaming cover entity for the front trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_ft") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Cover entity for the rear trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" if self.is_closed is not False: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = False self.async_write_ha_state() @@ -194,12 +375,55 @@ async def async_close_cover(self, **kwargs: Any) -> None: """Close rear trunk.""" if self.is_closed is not True: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = True self.async_write_ha_state() +class TeslemetryPollingRearTrunkEntity( + TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +): + """Base class for the rear trunk cover entities.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_rt") + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + +class TeslemetryStreamingRearTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryRearTrunkEntity, CoverRestoreEntity +): + """Polling cover entity for the rear trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + + class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): """Cover entity for the sunroof.""" @@ -210,7 +434,7 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): _attr_entity_registry_enabled_default = False def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the sensor.""" + """Initialize the cover.""" super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes @@ -232,7 +456,6 @@ def _async_update_attrs(self) -> None: async def async_open_cover(self, **kwargs: Any) -> None: """Open sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) self._attr_is_closed = False self.async_write_ha_state() @@ -240,7 +463,6 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) self._attr_is_closed = True self.async_write_ha_state() @@ -248,7 +470,6 @@ async def async_close_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) self._attr_is_closed = False self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 2b0ffd88cc6da1..42c8fea8d09ed4 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -2,18 +2,69 @@ from __future__ import annotations -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from collections.abc import Callable +from dataclasses import dataclass + +from teslemetry_stream import TeslemetryStreamVehicle +from teslemetry_stream.const import TeslaLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription): + """Describe a Teslemetry device tracker entity.""" + + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]], + Callable[[], None], + ] + name_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None] + ] + | None + ) = None + streaming_firmware: str + polling_prefix: str | None = None + + +DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( + TeslemetryDeviceTrackerEntityDescription( + key="location", + polling_prefix="drive_state", + value_listener=lambda x, y: x.listen_Location(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="route", + polling_prefix="drive_state_active_route", + value_listener=lambda x, y: x.listen_DestinationLocation(y), + name_listener=lambda x, y: x.listen_DestinationName(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="origin", + value_listener=lambda x, y: x.listen_OriginLocation(y), + streaming_firmware="2024.26", + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, @@ -21,67 +72,105 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry device tracker platform from a config entry.""" - async_add_entities( - klass(vehicle) - for klass in ( - TeslemetryDeviceTrackerLocationEntity, - TeslemetryDeviceTrackerRouteEntity, - ) - for vehicle in entry.runtime_data.vehicles - ) + entities: list[ + TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + ] = [] + for vehicle in entry.runtime_data.vehicles: + for description in DESCRIPTIONS: + if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if description.polling_prefix: + entities.append( + TeslemetryPollingDeviceTrackerEntity(vehicle, description) + ) + else: + entities.append( + TeslemetryStreamingDeviceTrackerEntity(vehicle, description) + ) + + async_add_entities(entities) -class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): - """Base class for Teslemetry tracker entities.""" +class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry Tracker Entities.""" - lat_key: str - lon_key: str + entity_description: TeslemetryDeviceTrackerEntityDescription def __init__( self, vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, ) -> None: """Initialize the device tracker.""" - super().__init__(vehicle, self.key) + self.entity_description = description + super().__init__(vehicle, description.key) def _async_update_attrs(self) -> None: - """Update the attributes of the device tracker.""" - + """Update the attributes of the entity.""" + self._attr_latitude = self.get( + f"{self.entity_description.polling_prefix}_latitude" + ) + self._attr_longitude = self.get( + f"{self.entity_description.polling_prefix}_longitude" + ) + self._attr_location_name = self.get( + f"{self.entity_description.polling_prefix}_destination" + ) + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME self._attr_available = ( - self.get(self.lat_key, False) is not None - and self.get(self.lon_key, False) is not None + self._attr_latitude is not None and self._attr_longitude is not None ) - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self.get(self.lat_key) - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self.get(self.lon_key) +class TeslemetryStreamingDeviceTrackerEntity( + TeslemetryVehicleStreamEntity, TrackerEntity, RestoreEntity +): + """Base class for Teslemetry Tracker Entities.""" -class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): - """Vehicle location device tracker class.""" + entity_description: TeslemetryDeviceTrackerEntityDescription - key = "location" - lat_key = "drive_state_latitude" - lon_key = "drive_state_longitude" - - -class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): - """Vehicle navigation device tracker class.""" - - key = "route" - lat_key = "drive_state_active_route_latitude" - lon_key = "drive_state_active_route_longitude" - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - location = self.get("drive_state_active_route_destination") - if location == "Home": - return STATE_HOME - return location + def __init__( + self, + vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, + ) -> None: + """Initialize the device tracker.""" + self.entity_description = description + super().__init__(vehicle, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_state = state.state + self._attr_latitude = state.attributes.get("latitude") + self._attr_longitude = state.attributes.get("longitude") + self._attr_location_name = state.attributes.get("location_name") + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._location_callback + ) + ) + if self.entity_description.name_listener: + self.async_on_remove( + self.entity_description.name_listener( + self.vehicle.stream_vehicle, self._name_callback + ) + ) + + def _location_callback(self, location: TeslaLocation | None) -> None: + """Update the value of the entity.""" + if location is None: + self._attr_available = False + else: + self._attr_available = True + self._attr_latitude = location.latitude + self._attr_longitude = location.longitude + self.async_write_ha_state() + + def _name_callback(self, name: str | None) -> None: + """Update the value of the entity.""" + self._attr_location_name = name + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index 7e9c8a9a5b0e9f..fc601a58ae6428 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics( ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), } for x in entry.runtime_data.energysites diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d14f3a42734c5b..df8406e0ced2a8 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,11 +3,14 @@ from abc import abstractmethod from typing import Any +from propcache import cached_property from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -21,18 +24,33 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData +class TeslemetryRootEntity(Entity): + """Parent class for all Teslemetry entities.""" + + _attr_has_entity_name = True + scoped: bool + api: VehicleSpecific | EnergySpecific + + def raise_for_scope(self, scope: Scope): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope", + translation_placeholders={"scope": scope}, + ) + + class TeslemetryEntity( + TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator - ] + ], ): - """Parent class for all Teslemetry entities.""" - - _attr_has_entity_name = True - scoped: bool + """Parent class for all Teslemetry Coordinator entities.""" def __init__( self, @@ -73,11 +91,6 @@ def is_none(self) -> bool: """Return if the value is a literal None.""" return self.get(self.key, False) is None - @property - def has(self) -> bool: - """Return True if a specific value is in coordinator data.""" - return self.key in self.coordinator.data - def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._async_update_attrs() @@ -87,15 +100,6 @@ def _handle_coordinator_update(self) -> None: def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - def raise_for_scope(self, scope: Scope): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_scope", - translation_placeholders={"scope": scope}, - ) - class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" @@ -139,6 +143,8 @@ def __init__( ) -> None: """Initialize common aspects of a Teslemetry Energy Site Live entity.""" + assert data.live_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device @@ -198,6 +204,8 @@ def __init__( ) -> None: """Initialize common aspects of a Teslemetry entity.""" + assert data.live_coordinator + self.api = data.api self.din = din self._attr_unique_id = f"{data.id}-{din}-{key}" @@ -236,3 +244,53 @@ def exists(self) -> bool: return self.key in self.coordinator.data.get("wall_connectors", {}).get( self.din, {} ) + + +class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): + """Parent class for Teslemetry Vehicle Stream entities.""" + + def __init__( + self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + self.streaming_key = streaming_key + self.vehicle = data + + self.api = data.api + self.stream = data.stream + self.vin = data.vin + self.add_field = data.stream.get_vehicle(self.vin).add_field + + self._attr_translation_key = key + self._attr_unique_id = f"{data.vin}-{key}" + self._attr_device_info = data.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.streaming_key: + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(self.streaming_key), + f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data: dict[str, Any]) -> None: + """Handle updated data from the stream.""" + self._async_value_from_stream(data["data"][self.streaming_key]) + self.async_write_ha_state() + + def _async_value_from_stream(self, value: Any) -> None: + """Update the entity with the latest value from the stream.""" + raise NotImplementedError + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a2782d25393c5e..7a3d0905ea1960 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.4.2"] + "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d3969b30a7caae..5b78386c68aa47 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -8,8 +8,9 @@ from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope -from teslemetry_stream import TeslemetryStream +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( @@ -34,12 +35,15 @@ class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" api: VehicleSpecific + config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream + stream_vehicle: TeslemetryStreamVehicle vin: str - wakelock = asyncio.Lock() + firmware: str device: DeviceInfo remove_listener: Callable + wakelock = asyncio.Lock() @dataclass @@ -47,7 +51,7 @@ class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" api: EnergySpecific - live_coordinator: TeslemetryEnergySiteLiveCoordinator + live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 95876cc2cf9db5..524d857970332d 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -5,10 +5,12 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from itertools import chain -from typing import cast + +from propcache import cached_property +from teslemetry_stream import Signal from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -40,6 +42,7 @@ TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -59,125 +62,165 @@ @dataclass(frozen=True, kw_only=True) -class TeslemetrySensorEntityDescription(SensorEntityDescription): +class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + polling: bool = False + polling_value_fn: Callable[[StateType], StateType] = lambda x: x + polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + streaming_key: Signal | None = None + streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_firmware: str = "2024.26" -VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( - TeslemetrySensorEntityDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charging_state", + polling=True, + streaming_key=Signal.DETAILED_CHARGE_STATE, + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), + streaming_value_fn=lambda value: CHARGE_STATES.get( + str(value).replace("DetailedChargeState", "") + ), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: CHARGE_STATES.get(cast(str, value)), ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", + polling=True, + streaming_key=Signal.BATTERY_LEVEL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", + polling=True, + streaming_key=Signal.AC_CHARGING_ENERGY_IN, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", + polling=True, + streaming_key=Signal.AC_CHARGING_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", + polling=True, + streaming_key=Signal.CHARGE_AMPS, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_rate", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", + polling=True, + streaming_key=Signal.CHARGING_CABLE_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", + polling=True, + streaming_key=Signal.FAST_CHARGER_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_range", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", + polling=True, + streaming_key=Signal.EST_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", + polling=True, + streaming_key=Signal.IDEAL_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_speed", + polling=True, + polling_value_fn=lambda value: value or 0, + streaming_key=Signal.VEHICLE_SPEED, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_power", + polling=True, + polling_value_fn=lambda value: value or 0, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", + polling=True, + polling_available_fn=lambda x: True, + polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), + streaming_key=Signal.GEAR, + streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", + polling=True, + streaming_key=Signal.ODOMETER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -185,8 +228,10 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -195,8 +240,10 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -205,8 +252,10 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -215,8 +264,10 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -225,22 +276,27 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", + polling=True, + streaming_key=Signal.INSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", + polling=True, + streaming_key=Signal.OUTSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_driver_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -248,8 +304,9 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_passenger_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -257,23 +314,29 @@ class TeslemetrySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", + polling=True, + streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", + polling=True, + streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", + polling=True, + streaming_key=Signal.MILES_TO_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -286,17 +349,21 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int + streaming_key: Signal + streaming_firmware: str = "2024.26" VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", + streaming_key=Signal.TIME_TO_FULL_CHARGE, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", + streaming_key=Signal.MINUTES_TO_ARRIVAL, device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -391,6 +458,14 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): ), ) + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( TeslemetrySensorEntityDescription( key="wall_connector_state", @@ -448,55 +523,109 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Add vehicles - TeslemetryVehicleSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Add vehicles time sensors - TeslemetryVehicleTimeSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_TIME_DESCRIPTIONS - ), - ( # Add energy site live - TeslemetryEnergyLiveSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - ), - ( # Add wall connectors - TeslemetryWallConnectorSensorEntity(energysite, din, description) - for energysite in entry.runtime_data.energysites - for din in energysite.live_coordinator.data.get("wall_connectors", {}) - for description in WALL_CONNECTOR_DESCRIPTIONS - ), - ( # Add energy site info - TeslemetryEnergyInfoSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if description.key in energysite.info_coordinator.data - ), - ( # Add energy history sensor - TeslemetryEnergyHistorySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.history_coordinator - ), - ) + + entities: list[SensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append(TeslemetryStreamSensorEntity(vehicle, description)) + elif description.polling: + entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) + + for time_description in VEHICLE_TIME_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and vehicle.firmware >= time_description.streaming_firmware + ): + entities.append( + TeslemetryStreamTimeSensorEntity(vehicle, time_description) + ) + else: + entities.append( + TeslemetryVehicleTimeSensorEntity(vehicle, time_description) + ) + + entities.extend( + TeslemetryEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data ) + entities.extend( + TeslemetryWallConnectorSensorEntity(energysite, din, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for din in energysite.live_coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ) + + entities.extend( + TeslemetryEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ) + + entities.extend( + TeslemetryEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.history_coordinator is not None + ) + + async_add_entities(entities) + + +class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryVehicleSensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryVehicleSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self.entity_description.streaming_value_fn(value) + class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" - entity_description: TeslemetrySensorEntityDescription + entity_description: TeslemetryVehicleSensorEntityDescription def __init__( self, data: TeslemetryVehicleData, - description: TeslemetrySensorEntityDescription, + description: TeslemetryVehicleSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -504,10 +633,46 @@ def __init__( def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.has: - self._attr_native_value = self.entity_description.value_fn(self._value) + if self.entity_description.polling_available_fn(self._value): + self._attr_available = True + self._attr_native_value = self.entity_description.polling_value_fn( + self._value + ) else: + self._attr_available = False + self._attr_native_value = None + + +class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEntity): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryTimeEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryTimeEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + self._get_timestamp = ignore_variance( + func=lambda value: dt_util.now() + timedelta(minutes=value), + ignored_variance=timedelta(minutes=description.variance), + ) + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: self._attr_native_value = None + else: + self._attr_native_value = self._get_timestamp(value) class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 97cfffa1699f3d..8215adb57112ba 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -98,7 +98,7 @@ def async_get_energy_site_for_entry( return energy_data -def async_register_services(hass: HomeAssistant) -> None: # noqa: C901 +def async_register_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" async def navigate_gps_request(call: ServiceCall) -> None: diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4f4bc2ae60c58d..8dc8b053712b0f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -51,7 +51,7 @@ "name": "Trip charging" }, "climate_state_cabin_overheat_protection_actively_cooling": { - "name": "Cabin overheat protection actively cooling" + "name": "Cabin overheat protection active" }, "climate_state_is_preconditioning": { "name": "Preconditioning" @@ -68,6 +68,27 @@ "storm_mode_active": { "name": "Storm watch active" }, + "automatic_blind_spot_camera": { + "name": "Automatic blind spot camera" + }, + "automatic_emergency_braking_off": { + "name": "Automatic emergency braking off" + }, + "blind_spot_collision_warning_chime": { + "name": "Blind spot collision warning chime" + }, + "bms_full_charge_complete": { + "name": "BMS full charge" + }, + "brake_pedal": { + "name": "Brake pedal" + }, + "charge_port_cold_weather_mode": { + "name": "Charge port cold weather mode" + }, + "service_mode": { + "name": "Service mode" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, @@ -109,6 +130,66 @@ }, "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" + }, + "pin_to_drive_enabled": { + "name": "Pin to drive enabled" + }, + "drive_rail": { + "name": "Drive rail" + }, + "driver_seat_belt": { + "name": "Driver seat belt" + }, + "driver_seat_occupied": { + "name": "Driver seat occupied" + }, + "passenger_seat_belt": { + "name": "Passenger seat belt" + }, + "fast_charger_present": { + "name": "Fast charger present" + }, + "gps_state": { + "name": "GPS state" + }, + "guest_mode_enabled": { + "name": "Guest mode enabled" + }, + "dc_dc_enable": { + "name": "DC to DC converter" + }, + "emergency_lane_departure_avoidance": { + "name": "Emergency lane departure avoidance" + }, + "supercharger_session_trip_planner": { + "name": "Supercharger session trip planner" + }, + "wiper_heat_enabled": { + "name": "Wiper heat" + }, + "rear_display_hvac_enabled": { + "name": "Rear display HVAC" + }, + "offroad_lightbar_present": { + "name": "Offroad lightbar" + }, + "homelink_nearby": { + "name": "Homelink nearby" + }, + "europe_vehicle": { + "name": "European vehicle" + }, + "right_hand_drive": { + "name": "Right hand drive" + }, + "located_at_home": { + "name": "Located at home" + }, + "located_at_work": { + "name": "Located at work" + }, + "located_at_favorite": { + "name": "Located at favorite" } }, "button": { @@ -155,6 +236,9 @@ }, "route": { "name": "Route" + }, + "origin": { + "name": "Origin" } }, "lock": { @@ -610,7 +694,7 @@ }, "services": { "navigation_gps_request": { - "description": "Set vehicle navigation to the provided latitude/longitude coordinates.", + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", "fields": { "device_id": { "description": "Vehicle to share to.", @@ -646,7 +730,7 @@ "name": "Set scheduled charging" }, "set_scheduled_departure": { - "description": "Sets a time at which departure should be completed.", + "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.", "fields": { "departure_time": { "description": "Time to be preconditioned by.", @@ -684,7 +768,7 @@ "name": "Set scheduled departure" }, "speed_limit": { - "description": "Activate the speed limit of the vehicle.", + "description": "Activates the speed limit of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", @@ -702,7 +786,7 @@ "name": "Set speed limit" }, "time_of_use": { - "description": "Update the time of use settings for the energy site.", + "description": "Updates the time of use settings for an energy site.", "fields": { "device_id": { "description": "Energy Site to configure.", @@ -716,7 +800,7 @@ "name": "Time of use settings" }, "valet_mode": { - "description": "Activate the valet mode of the vehicle.", + "description": "Activates the valet mode of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 51348afb0a4a3d..2c066d785cafe7 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.0"] + "requirements": ["thermopro-ble==0.10.1"] } diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index f5a4fcef8fda0e..8b3eb7b53c4c35 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -2,12 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to The Things Network v3 App", - "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "title": "Connect to The Things Network v3", + "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.", "data": { - "hostname": "[%key:common::config_flow::data::host%]", + "host": "[%key:common::config_flow::data::host%]", "app_id": "Application ID", - "access_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index abf3e60447299a..4e44b2b1ffd9dd 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -4,8 +4,8 @@ import logging import re -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 568b76d4999396..bf202a50c34798 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -4,8 +4,9 @@ from typing import Any -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +29,7 @@ async def async_step_user( return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Set up because the user has border routers.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 65d4c9d044ccb7..868ced022b8b9d 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index d5d7e33a5e008e..fed4ff332fc698 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -8,10 +8,10 @@ from tololib import ToloClient, ToloCommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -61,7 +61,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml index fb0f1e5098a707..2ec54250b728ec 100644 --- a/homeassistant/components/totalconnect/quality_scale.yaml +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -1,11 +1,11 @@ rules: # Bronze - config-flow: todo + config-flow: done test-before-configure: done unique-config-entry: done config-flow-test-coverage: todo runtime-data: done - test-before-setup: todo + test-before-setup: done appropriate-polling: done entity-unique-id: done has-entity-name: done @@ -15,7 +15,7 @@ rules: common-modules: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done @@ -47,13 +47,11 @@ rules: discovery-update-info: todo repair-issues: todo docs-use-cases: done - - # stopped here.... - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done docs-examples: done # Platinum diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 004056ef9ac94a..daf720084a5044 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,21 +2,36 @@ "config": { "step": { "user": { + "title": "Total Connect 2.0 Account Credentials", + "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The Total Connect username", + "password": "The Total Connect password" } }, "locations": { "title": "Location Usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { - "usercode": "Usercode" + "usercodes": "Usercode" + }, + "data_description": { + "usercodes": "The usercode is usually a 4 digit number" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Total Connect needs to re-authenticate your account" + "description": "Total Connect needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::totalconnect::config::step::user::data_description::password%]" + } } }, "error": { @@ -36,6 +51,10 @@ "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" + }, + "data_description": { + "auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.", + "code_required": "If enabled, you must enter the user code to arm or disarm the alarm" } } } diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e2a2f99517f553..31bdcc5481cd25 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from aiohttp import ClientSession from kasa import ( @@ -18,11 +18,9 @@ KasaException, ) from kasa.httpclient import get_cookie_jar -from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_AUTHENTICATION, @@ -59,10 +57,7 @@ DOMAIN, PLATFORMS, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .models import TPLinkData - -type TPLinkConfigEntry = ConfigEntry[TPLinkData] +from .coordinator import TPLinkConfigEntry, TPLinkData, TPLinkDataUpdateCoordinator DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -178,9 +173,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if not credentials and entry_credentials_hash: data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH} hass.config_entries.async_update_entry(entry, data=data) - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex device_credentials_hash = device.credentials_hash @@ -212,21 +221,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + translation_domain=DOMAIN, + translation_key="unexpected_device", + translation_placeholders={ + "host": host, + # all entries have a unique id + "expected": cast(str, entry.unique_id), + "found": found_mac, + }, ) - parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) - child_coordinators: list[TPLinkDataUpdateCoordinator] = [] - - # The iot HS300 allows a limited number of concurrent requests and fetching the - # emeter information requires separate ones so create child coordinators here. - if isinstance(device, IotStrip): - child_coordinators = [ - # The child coordinators only update energy data so we can - # set a longer update interval to avoid flooding the device - TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) - for child in device.children - ] + parent_coordinator = TPLinkDataUpdateCoordinator( + hass, device, timedelta(seconds=5), entry + ) camera_creds: Credentials | None = None if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS): @@ -235,9 +242,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo ) live_view = entry.data.get(CONF_LIVE_VIEW) - entry.runtime_data = TPLinkData( - parent_coordinator, child_coordinators, camera_creds, live_view - ) + entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -263,7 +268,7 @@ def legacy_device_id(device: Device) -> str: return device_id.split("_")[1] -def get_device_name(device: Device, parent: Device | None = None) -> str: +def get_device_name(device: Device, parent: Device | None = None) -> str | None: """Get a name for the device. alias can be none on some devices.""" if device.alias: return device.alias @@ -278,7 +283,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str: ] suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else "" return f"{device.device_type.value.capitalize()}{suffix}" - return f"Unnamed {device.model}" + return None async def get_credentials(hass: HomeAssistant) -> Credentials | None: @@ -325,7 +330,9 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TPLinkConfigEntry +) -> bool: """Migrate old entry.""" entry_version = config_entry.version entry_minor_version = config_entry.minor_version diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index f3a7e7a7ce7f9b..6153ec31de12db 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -8,6 +8,7 @@ from kasa import Feature from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -16,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -23,9 +25,12 @@ class TPLinkBinarySensorEntityDescription( BinarySensorEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based binary sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + BINARY_SENSOR_DESCRIPTIONS: Final = ( TPLinkBinarySensorEntityDescription( key="overheated", @@ -39,11 +44,6 @@ class TPLinkBinarySensorEntityDescription( key="cloud_connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), - # To be replaced & disabled per default by the upcoming update platform. - TPLinkBinarySensorEntityDescription( - key="update_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), TPLinkBinarySensorEntityDescription( key="temperature_warning", ), @@ -75,19 +75,30 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.BinarySensor, - entity_class=TPLinkBinarySensorEntity, - descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.BinarySensor, + entity_class=TPLinkBinarySensorEntity, + descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated( + hass, BINARY_SENSOR_DOMAIN, config_entry.entry_id, entities + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 753efcf89f4d92..990f0a608d3711 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -29,6 +29,10 @@ class TPLinkButtonEntityDescription( """Base class for a TPLink feature based button entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: Final = [ TPLinkButtonEntityDescription( key="test_alarm", @@ -79,20 +83,27 @@ async def async_setup_entry( """Set up buttons.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Action, - entity_class=TPLinkButtonEntity, - descriptions=BUTTON_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Action, + entity_class=TPLinkButtonEntity, + descriptions=BUTTON_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 01b47db708282f..e1db72544282e0 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -36,6 +36,10 @@ class TPLinkCameraEntityDescription( """Base class for camera entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( TPLinkCameraEntityDescription( key="live_view", diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index f53a0d093ac3e0..e8b7336f391e78 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -21,10 +21,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .const import UNIT_MAPPING +from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + # Upstream state to HVACAction STATE_TO_ACTION = { ThermostatState.Idle: HVACAction.IDLE, @@ -100,7 +104,13 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: elif hvac_mode is HVACMode.OFF: await self._state_feature.set_value(False) else: - raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_mode", + translation_placeholders={ + "mode": hvac_mode, + }, + ) @async_refresh_after async def async_turn_on(self) -> None: diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 9bc278f8948b32..9ca2fe80cf9176 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -18,7 +18,7 @@ ) import voluptuous as vol -from homeassistant.components import dhcp, ffmpeg, stream +from homeassistant.components import ffmpeg, stream from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -40,6 +40,7 @@ ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import ( @@ -93,7 +94,7 @@ def __init__(self) -> None: self._discovered_device: Device | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 1c362d33746b04..186840e8faf446 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -2,38 +2,57 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from kasa import AuthenticationError, Device, KasaException +from kasa import AuthenticationError, Credentials, Device, KasaException +from kasa.iot import IotStrip -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + camera_credentials: Credentials | None + live_view: bool | None + + +type TPLinkConfigEntry = ConfigEntry[TPLinkData] + REQUEST_REFRESH_DELAY = 0.35 class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific TPLink device.""" - config_entry: config_entries.ConfigEntry + config_entry: TPLinkConfigEntry def __init__( self, hass: HomeAssistant, device: Device, update_interval: timedelta, + config_entry: TPLinkConfigEntry, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device super().__init__( hass, _LOGGER, + config_entry=config_entry, name=device.host, update_interval=update_interval, # We don't want an immediate refresh since the device @@ -42,12 +61,74 @@ def __init__( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) + self._previous_child_device_ids = {child.device_id for child in device.children} + self.removed_child_device_ids: set[str] = set() + self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {} async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: await self.device.update(update_children=False) except AuthenticationError as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex + + await self._process_child_devices() + + async def _process_child_devices(self) -> None: + """Process child devices and remove stale devices.""" + current_child_device_ids = {child.device_id for child in self.device.children} + if ( + stale_device_ids := self._previous_child_device_ids + - current_child_device_ids + ): + device_registry = dr.async_get(self.hass) + for device_id in stale_device_ids: + 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, + ) + child_coordinator = self._child_coordinators.pop(device_id, None) + if child_coordinator: + await child_coordinator.async_shutdown() + + self._previous_child_device_ids = current_child_device_ids + self.removed_child_device_ids = stale_device_ids + + def get_child_coordinator( + self, + child: Device, + ) -> TPLinkDataUpdateCoordinator: + """Get separate child coordinator for a device or self if not needed.""" + # The iot HS300 allows a limited number of concurrent requests and fetching the + # emeter information requires separate ones so create child coordinators here. + if isinstance(self.device, IotStrip): + if not (child_coordinator := self._child_coordinators.get(child.device_id)): + # The child coordinators only update energy data so we can + # set a longer update interval to avoid flooding the device + child_coordinator = TPLinkDataUpdateCoordinator( + self.hass, child, timedelta(seconds=60), self.config_entry + ) + self._child_coordinators[child.device_id] = child_coordinator + return child_coordinator + + return self diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 935857e5db1a04..178c8bfdd3dedd 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -162,6 +162,9 @@ def __init__( registry_device = device device_name = get_device_name(device, parent=parent) + translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None + if parent and parent.device_type is not Device.Type.Hub: if not feature or feature.id == PRIMARY_STATE_ID: # Entity will be added to parent if not a hub and no feature @@ -169,6 +172,9 @@ def __init__( # is the primary state registry_device = parent device_name = get_device_name(registry_device) + if not device_name: + translation_key = "unnamed_device" + translation_placeholders = {"model": parent.model} else: # Prefix the device name with the parent name unless it is a # hub attached device. Sensible default for child devices like @@ -177,13 +183,28 @@ def __init__( # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan # and Dimmer Switch for both so should be distinguished by the # parent name. - device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}" + parent_device_name = get_device_name(parent) + child_device_name = get_device_name(device, parent=parent) + if parent_device_name: + device_name = f"{parent_device_name} {child_device_name}" + else: + device_name = None + translation_key = "unnamed_device" + translation_placeholders = { + "model": f"{parent.model} {child_device_name}" + } + + if device_name is None and not translation_key: + translation_key = "unnamed_device" + translation_placeholders = {"model": device.model} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(registry_device.device_id))}, manufacturer="TP-Link", model=registry_device.model, name=device_name, + translation_key=translation_key, + translation_placeholders=translation_placeholders, sw_version=registry_device.hw_info["sw_ver"], hw_version=registry_device.hw_info["hw_ver"], ) @@ -320,6 +341,7 @@ def _description_for_feature[_D: EntityDescription]( if descriptions and (desc := descriptions.get(feature.id)): translation_key: str | None = feature.id + # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name # > device_class (if base platform supports). @@ -412,7 +434,8 @@ def entities_for_device_and_its_children[ feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], - child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, + known_child_device_ids: set[str], + first_check: bool, ) -> list[_E]: """Create entities for device and its children. @@ -420,36 +443,69 @@ def entities_for_device_and_its_children[ """ entities: list[_E] = [] # Add parent entities before children so via_device id works. - entities.extend( - cls._entities_for_device( + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + ) + ) + + # Remove any device ids removed via the coordinator so they can be re-added + for removed_child_id in coordinator.removed_child_device_ids: + _LOGGER.debug( + "Removing %s from known %s child ids for device %s" + "as it has been removed by the coordinator", + removed_child_id, + entity_class.__name__, + device.host, + ) + known_child_device_ids.discard(removed_child_id) + + current_child_devices = {child.device_id: child for child in device.children} + current_child_device_ids = set(current_child_devices.keys()) + new_child_device_ids = current_child_device_ids - known_child_device_ids + children = [] + + if new_child_device_ids: + children = [ + child + for child_id, child in current_child_devices.items() + if child_id in new_child_device_ids + ] + known_child_device_ids.update(new_child_device_ids) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + for child in children: + child_coordinator = coordinator.get_child_coordinator(child) + + child_entities = cls._entities_for_device( hass, - device, - coordinator=coordinator, + child, + coordinator=child_coordinator, feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + parent=device, ) - ) - if device.children: - _LOGGER.debug("Initializing device with %s children", len(device.children)) - for idx, child in enumerate(device.children): - # HS300 does not like too many concurrent requests and its - # emeter data requires a request for each socket, so we receive - # separate coordinators. - if child_coordinators: - child_coordinator = child_coordinators[idx] - else: - child_coordinator = coordinator - entities.extend( - cls._entities_for_device( - hass, - child, - coordinator=child_coordinator, - feature_type=feature_type, - entity_class=entity_class, - descriptions=descriptions, - parent=device, - ) - ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) return entities diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index a1e62e4ed6975f..92cf049c11ac57 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -20,6 +20,10 @@ from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 9cc0326b59fd5c..aedbccfbd51d05 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -125,12 +125,6 @@ "signal_level": { "default": "mdi:signal" }, - "current_firmware_version": { - "default": "mdi:information" - }, - "available_firmware_version": { - "default": "mdi:information-outline" - }, "alarm_source": { "default": "mdi:bell" }, diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 91e2a784af2676..731ee919c98c1d 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -6,7 +6,7 @@ import logging from typing import Any -from kasa import Device, DeviceType, LightState, Module +from kasa import Device, DeviceType, KasaException, LightState, Module from kasa.interfaces import Light, LightEffect from kasa.iot import IotDevice import voluptuous as vol @@ -24,15 +24,21 @@ filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) SERVICE_RANDOM_EFFECT = "random_effect" @@ -355,6 +361,8 @@ def _async_update_attrs(self) -> bool: class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + def __init__( self, device: Device, @@ -367,8 +375,6 @@ def __init__( self._effect_module = effect_module super().__init__(device, coordinator, light_module=light_module) - _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - @callback def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" @@ -458,7 +464,17 @@ async def async_set_random_effect( if transition_range: effect["transition_range"] = transition_range effect["transition"] = 0 - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex async def async_set_sequence_effect( self, @@ -480,4 +496,14 @@ async def async_set_sequence_effect( "spread": spread, "direction": direction, } - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 7797f0a36a3167..a975e675ceb555 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.9.0"] + "requirements": ["python-kasa[speedups]==0.9.1"] } diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py deleted file mode 100644 index 389260a388bf48..00000000000000 --- a/homeassistant/components/tplink/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""The tplink integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from kasa import Credentials - -from .coordinator import TPLinkDataUpdateCoordinator - - -@dataclass(slots=True) -class TPLinkData: - """Data for the tplink integration.""" - - parent_coordinator: TPLinkDataUpdateCoordinator - children_coordinators: list[TPLinkDataUpdateCoordinator] - camera_credentials: Credentials | None - live_view: bool | None diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 3f7fa9c3e0fb1b..97152ef4da8fc5 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -9,6 +9,7 @@ from kasa import Device, Feature from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, NumberMode, @@ -17,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -31,7 +33,12 @@ class TPLinkNumberEntityDescription( NumberEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based number entity description.""" + + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 NUMBER_DESCRIPTIONS: Final = ( @@ -69,26 +76,34 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up number entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Number, - entity_class=TPLinkNumberEntity, - descriptions=NUMBER_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Number, + entity_class=TPLinkNumberEntity, + descriptions=NUMBER_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, NUMBER_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) - async_add_entities(entities) + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): - """Representation of a feature-based TPLink sensor.""" + """Representation of a feature-based TPLink number entity.""" entity_description: TPLinkNumberEntityDescription @@ -101,7 +116,7 @@ def __init__( description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: - """Initialize the a switch.""" + """Initialize the number entity.""" super().__init__( device, coordinator, feature=feature, description=description, parent=parent ) diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml new file mode 100644 index 00000000000000..ced9cbcc8311ba --- /dev/null +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: The integration does not use events. + dependency-transparency: done + action-setup: + status: exempt + comment: The integration only uses platform services. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: done + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 5dd8e54fca8a53..a443546fdaa6be 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -7,11 +7,16 @@ from kasa import Device, Feature -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -24,8 +29,12 @@ class TPLinkSelectEntityDescription( SelectEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based select entity description.""" + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 SELECT_DESCRIPTIONS: Final = [ TPLinkSelectEntityDescription( @@ -47,22 +56,30 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up select entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Choice, + entity_class=TPLinkSelectEntity, + descriptions=SELECT_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SELECT_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Choice, - entity_class=TPLinkSelectEntity, - descriptions=SELECT_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index da4bf72122d8f2..0898a3379d13dd 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -30,6 +30,9 @@ class TPLinkSensorEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", @@ -113,11 +116,6 @@ class TPLinkSensorEntityDescription( TPLinkSensorEntityDescription( key="alarm_source", ), - TPLinkSensorEntityDescription( - key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -131,20 +129,27 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Sensor, - entity_class=TPLinkSensorEntity, - descriptions=SENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Sensor, + entity_class=TPLinkSensorEntity, + descriptions=SENSOR_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 141ea696358f6c..bd1bfcead6db08 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -1,4 +1,4 @@ -"""Support for TPLink hub alarm.""" +"""Support for TPLink siren entity.""" from __future__ import annotations @@ -15,6 +15,10 @@ from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -31,7 +35,7 @@ async def async_setup_entry( class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): - """Representation of a tplink hub alarm.""" + """Representation of a tplink siren entity.""" _attr_name = None _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index c0aef09e8c3243..9c32dd5bbf46ba 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -14,6 +14,9 @@ "pick_device": { "data": { "device": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device": "Pick the TP-Link device to add." } }, "discovery_confirm": { @@ -25,6 +28,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your TP-Link cloud username which is the full email and is case sensitive.", + "password": "Your TP-Link cloud password which is case sensitive." } }, "discovery_auth_confirm": { @@ -33,6 +40,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]", + "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]" } }, "reauth_confirm": { @@ -41,6 +52,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]", + "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]" } }, "reconfigure": { @@ -48,15 +63,23 @@ "description": "Update your configuration for device {mac}", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::tplink::config::step::user::data_description::host%]" } }, "camera_auth_confirm": { "title": "Set camera account credentials", - "description": "Input device camera account credentials. Leave blank if they are the same as your TPLink cloud credentials.", + "description": "Input device camera account credentials.", "data": { "live_view": "Enable camera live view", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "live_view": "Enabling live view will create the live view camera entity and requires your camera account credentials.", + "username": "Your camera account username configured for the device in the Tapo app.", + "password": "Your camera account password configured for the device in the Tapo app." } } }, @@ -86,26 +109,9 @@ "overheated": { "name": "Overheated" }, - "battery_low": { - "name": "Battery low" - }, "cloud_connection": { "name": "Cloud connection" }, - "update_available": { - "name": "[%key:component::binary_sensor::entity_component::update::name%]", - "state": { - "off": "[%key:component::binary_sensor::entity_component::update::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::update::state::on%]" - } - }, - "is_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]", - "state": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - } - }, "water_alert": { "name": "[%key:component::binary_sensor::entity_component::moisture::name%]", "state": { @@ -172,27 +178,6 @@ "signal_level": { "name": "Signal level" }, - "current_firmware_version": { - "name": "Current firmware version" - }, - "available_firmware_version": { - "name": "Available firmware version" - }, - "battery_level": { - "name": "Battery level" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "device_time": { "name": "Device time" }, @@ -207,9 +192,6 @@ }, "alarm_source": { "name": "Alarm source" - }, - "rssi": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" } }, "switch": { @@ -268,6 +250,11 @@ } } }, + "device": { + "unnamed_device": { + "name": "Unnamed {model}" + } + }, "services": { "sequence_effect": { "name": "Sequence effect", @@ -371,6 +358,15 @@ }, "device_authentication": { "message": "Device authentication error {func}: {exc}" + }, + "set_custom_effect": { + "message": "Error trying to set custom effect {effect}: {exc}" + }, + "unexpected_device": { + "message": "Unexpected device found at {host}; expected {expected}, found {found}" + }, + "unsupported_mode": { + "message": "Tried to set unsupported mode: {mode}" } }, "issues": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 7a879fb3c70294..92ecd7992de57d 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -8,11 +8,16 @@ from kasa import Feature -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription, @@ -26,8 +31,12 @@ class TPLinkSwitchEntityDescription( SwitchEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based switch entity description.""" + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( @@ -80,17 +89,26 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Switch, - entity_class=TPLinkSwitch, - descriptions=SWITCH_DESCRIPTIONS_MAP, - ) - - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Switch, + entity_class=TPLinkSwitch, + descriptions=SWITCH_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SWITCH_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index d9911472a6721a..29d876346a7086 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -10,10 +10,13 @@ from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN @@ -78,12 +81,10 @@ async def async_step_auth( ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) host = discovery_info.host @@ -96,7 +97,7 @@ async def async_step_homekit( if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + unique_id=discovery_info.properties[ATTR_PROPERTIES_ID], ) return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index d09077dd01afe3..19f88817e711ae 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -4,11 +4,21 @@ import logging +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + TrafikverketTrain, + UnknownError, +) + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import PLATFORMS +from .const import CONF_FROM, CONF_TO, PLATFORMS from .coordinator import TVDataUpdateCoordinator TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] @@ -52,13 +62,55 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 1: + if entry.version > 2: # This means the user has downgraded from a future version return False - if entry.version == 1 and entry.minor_version == 1: - # Remove unique id - hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2) + if entry.version == 1: + if entry.minor_version == 1: + # Remove unique id + hass.config_entries.async_update_entry( + entry, unique_id=None, minor_version=2 + ) + + # Change from station names to station signatures + try: + web_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(web_session, entry.data[CONF_API_KEY]) + from_stations = await train_api.async_search_train_stations( + entry.data[CONF_FROM] + ) + to_stations = await train_api.async_search_train_stations( + entry.data[CONF_TO] + ) + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except NoTrainStationFound as error: + _LOGGER.error( + "Migration failed as no train station found with provided name %s", + str(error), + ) + return False + except UnknownError as error: + _LOGGER.error("Unknown error occurred during validation %s", str(error)) + return False + except Exception as error: # noqa: BLE001 + _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + return False + + if len(from_stations) > 1 or len(to_stations) > 1: + _LOGGER.error( + "Migration failed as more than one station found with provided name" + ) + return False + + new_data = entry.data.copy() + new_data[CONF_FROM] = from_stations[0].signature + new_data[CONF_TO] = to_stations[0].signature + + hass.config_entries.async_update_entry( + entry, data=new_data, version=2, minor_version=1 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 363b9bb2542ead..da1fb0f7622eb6 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -3,21 +3,20 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime import logging from typing import Any -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, UnknownError, ) import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -28,16 +27,15 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, TextSelector, TimeSelector, ) -import homeassistant.util.dt as dt_util from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import next_departuredate _LOGGER = logging.getLogger(__name__) @@ -68,49 +66,23 @@ ) -async def validate_input( +async def validate_station( hass: HomeAssistant, api_key: str, - train_from: str, - train_to: str, - train_time: str | None, - weekdays: list[str], - product_filter: str | None, -) -> dict[str, str]: + train_station: str, + field: str, +) -> tuple[list[StationInfoModel], dict[str, str]]: """Validate input from user input.""" errors: dict[str, str] = {} - - when = dt_util.now() - if train_time: - departure_day = next_departuredate(weekdays) - if _time := dt_util.parse_time(train_time): - when = datetime.combine( - departure_day, - _time, - dt_util.get_default_time_zone(), - ) - + stations = [] try: web_session = async_get_clientsession(hass) train_api = TrafikverketTrain(web_session, api_key) - from_station = await train_api.async_search_train_station(train_from) - to_station = await train_api.async_search_train_station(train_to) - if train_time: - await train_api.async_get_train_stop( - from_station, to_station, when, product_filter - ) - else: - await train_api.async_get_next_train_stop( - from_station, to_station, when, product_filter - ) + stations = await train_api.async_search_train_stations(train_station) except InvalidAuthentication: errors["base"] = "invalid_auth" except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except NoTrainAnnouncementFound: - errors["base"] = "no_trains" + errors[field] = "invalid_station" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" @@ -118,14 +90,18 @@ async def validate_input( _LOGGER.error("Unknown exception occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - return errors + return (stations, errors) class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 + + _from_stations: list[StationInfoModel] + _to_stations: list[StationInfoModel] + _data: dict[str, Any] @staticmethod @callback @@ -151,14 +127,11 @@ async def async_step_reauth_confirm( api_key = user_input[CONF_API_KEY] reauth_entry = self._get_reauth_entry() - errors = await validate_input( + _, errors = await validate_station( self.hass, api_key, reauth_entry.data[CONF_FROM], - reauth_entry.data[CONF_TO], - reauth_entry.data.get(CONF_TIME), - reauth_entry.data[CONF_WEEKDAY], - reauth_entry.options.get(CONF_FILTER_PRODUCT), + CONF_FROM, ) if not errors: return self.async_update_reload_and_abort( @@ -174,6 +147,18 @@ async def async_step_reauth_confirm( async def async_step_user( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_initial( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step.""" errors: dict[str, str] = {} @@ -193,27 +178,99 @@ async def async_step_user( if train_time: name = f"{train_from} to {train_to} at {train_time}" - errors = await validate_input( - self.hass, - api_key, - train_from, - train_to, - train_time, - train_days, - filter_product, + self._from_stations, from_errors = await validate_station( + self.hass, api_key, train_from, CONF_FROM ) + self._to_stations, to_errors = await validate_station( + self.hass, api_key, train_to, CONF_TO + ) + errors = {**from_errors, **to_errors} + if not errors: - self._async_abort_entries_match( - { - CONF_API_KEY: api_key, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - CONF_FILTER_PRODUCT: filter_product, - } + if len(self._from_stations) == 1 and len(self._to_stations) == 1: + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + self._data = user_input + return await self.async_step_select_stations() + + return self.async_show_form( + step_id="initial", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or {} + ), + errors=errors, + ) + + async def async_step_select_stations( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the select station step.""" + if user_input is not None: + api_key: str = self._data[CONF_API_KEY] + train_from: str = user_input[CONF_FROM] + train_to: str = user_input[CONF_TO] + train_time: str | None = self._data.get(CONF_TIME) + train_days: list = self._data[CONF_WEEKDAY] + filter_product: str | None = self._data[CONF_FILTER_PRODUCT] + + if filter_product == "": + filter_product = None + + name = f"{self._data[CONF_FROM]} to {self._data[CONF_TO]}" + if train_time: + name = ( + f"{self._data[CONF_FROM]} to {self._data[CONF_TO]} at {train_time}" ) - return self.async_create_entry( + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: train_from, + CONF_TO: user_input[CONF_TO], + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, title=name, data={ CONF_API_KEY: api_key, @@ -225,13 +282,45 @@ async def async_step_user( }, options={CONF_FILTER_PRODUCT: filter_product}, ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + from_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._from_stations + ] + to_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._to_stations + ] + schema = {} + if len(from_options) > 1: + schema[vol.Required(CONF_FROM)] = SelectSelector( + SelectSelectorConfig( + options=from_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) + if len(to_options) > 1: + schema[vol.Required(CONF_TO)] = SelectSelector( + SelectSelectorConfig( + options=to_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) return self.async_show_form( - step_id="user", + step_id="select_stations", data_schema=self.add_suggested_values_to_schema( - DATA_SCHEMA, user_input or {} + vol.Schema(schema), user_input or {} ), - errors=errors, ) diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index c4e1a41837140a..28c9ab6fe8e9db 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -7,15 +7,16 @@ import logging from typing import TYPE_CHECKING -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import StationInfoModel, TrainStopModel from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant @@ -93,11 +94,15 @@ def __init__(self, hass: HomeAssistant, config_entry: TVTrainConfigEntry) -> Non async def _async_setup(self) -> None: """Initiate stations.""" try: - self.to_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_TO] + self.to_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_TO] + ) ) - self.from_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_FROM] + self.from_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_FROM] + ) ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 89542211a92477..02155e46c2faca 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,7 +14,7 @@ "incorrect_api_key": "Invalid API key for selected account" }, "step": { - "user": { + "initial": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "to": "To station", @@ -27,6 +28,13 @@ "filter_product": "To filter by product description add the phrase here to match" } }, + "select_stations": { + "description": "More than one station was found with the provided name, select the correct ones from the provided lists", + "data": { + "to": "To station", + "from": "From station" + } + }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -38,10 +46,10 @@ "step": { "init": { "data": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data::filter_product%]" }, "data_description": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data_description::filter_product%]" } } } diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index aabc5827a888d9..0fe1953d31e92d 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up Transmission Client", + "title": "Set up Transmission client", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", @@ -96,7 +96,7 @@ "fields": { "entry_id": { "name": "Transmission entry", - "description": "Config entry id." + "description": "ID of the config entry to use." }, "torrent": { "name": "Torrent", diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 69e8daa3ce7e1d..16c7067c7ceca6 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.2.1"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index e7d1091719bf5d..0213fd17864a65 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -73,23 +73,23 @@ from .models import Voice __all__ = [ - "async_default_engine", - "async_get_media_source_audio", - "async_support_options", "ATTR_AUDIO_OUTPUT", "ATTR_PREFERRED_FORMAT", - "ATTR_PREFERRED_SAMPLE_RATE", - "ATTR_PREFERRED_SAMPLE_CHANNELS", "ATTR_PREFERRED_SAMPLE_BYTES", + "ATTR_PREFERRED_SAMPLE_CHANNELS", + "ATTR_PREFERRED_SAMPLE_RATE", "CONF_LANG", "DEFAULT_CACHE_DIR", - "generate_media_source_id", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SampleFormat", + "PLATFORM_SCHEMA_BASE", "Provider", + "SampleFormat", "TtsAudioType", "Voice", + "async_default_engine", + "async_get_media_source_audio", + "async_support_options", + "generate_media_source_id", ] _LOGGER = logging.getLogger(__name__) @@ -1052,10 +1052,8 @@ async def post(self, request: web.Request) -> web.Response: data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if ( - not data.get("engine_id") - and not data.get(ATTR_PLATFORM) - or not data.get(ATTR_MESSAGE) + if (not data.get("engine_id") and not data.get(ATTR_PLATFORM)) or not data.get( + ATTR_MESSAGE ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 738492102a1481..bab9ac309ecfdf 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -89,9 +89,8 @@ def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if ( - self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) - or ( + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( + ( enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d163ae4e5648d9..69c509b9edf618 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -70,8 +70,7 @@ def _handle_coordinator_update(self) -> None: waste_dates and ( next_waste_pickup_date is None - or waste_dates[0] # type: ignore[unreachable] - < next_waste_pickup_date + or waste_dates[0] < next_waste_pickup_date ) and waste_dates[0] >= dt_util.now().date() ): diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 53ba8f084c3627..0f2f87302afc3e 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -9,10 +9,10 @@ from ttls.client import Twinkly from voluptuous import Required, Schema -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN @@ -58,7 +58,7 @@ async def async_step_user( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery for twinkly.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 2542d325b478d6..38e5c9a6fc7085 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -35,7 +35,7 @@ class TwinklyModeSelect(TwinklyEntity, SelectEntity): def __init__(self, coordinator: TwinklyCoordinator) -> None: """Initialize TwinklyModeSelect.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data.device_info["mac"]}_mode" + self._attr_unique_id = f"{coordinator.data.device_info['mac']}_mode" self.client = coordinator.client @property diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 63c8533aa2ecea..479055b84eb6a9 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -18,7 +18,6 @@ from aiounifi.interfaces.sites import Sites import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -36,6 +35,11 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import UnifiConfigEntry from .const import ( @@ -212,12 +216,12 @@ async def async_step_reauth( return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] + mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) self.config = { CONF_HOST: parsed_url.hostname, diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index f11ddefec98a8b..64403152b0c234 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -47,9 +47,13 @@ def __init__(self, hub: UnifiHub) -> None: hub.api.sites.update, hub.api.system_information.update, hub.api.traffic_rules.update, + hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = (hub.api.traffic_rules.update,) + self.polling_api_updaters = ( + hub.api.traffic_rules.update, + hub.api.traffic_routes.update, + ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] self._dataUpdateCoordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 76990c1c4a1bf8..6874bb5ae03d89 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -61,6 +61,9 @@ "traffic_rule_control": { "default": "mdi:security-network" }, + "traffic_route_control": { + "default": "mdi:routes" + }, "poe_port_control": { "default": "mdi:ethernet", "state": { diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index ce726a0f5d025d..fc63c092d5624f 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -69,8 +69,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): if config_entry.state is not ConfigEntryState.LOADED or ( - (hub := config_entry.runtime_data) - and not hub.available + ((hub := config_entry.runtime_data) and not hub.available) or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -87,10 +86,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Neither IP, hostname nor name is configured. """ for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if ( - config_entry.state is not ConfigEntryState.LOADED - or (hub := config_entry.runtime_data) - and not hub.available + if config_entry.state is not ConfigEntryState.LOADED or ( + (hub := config_entry.runtime_data) and not hub.available ): continue diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 01843a8a95b810..7741e57c82cac4 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -20,6 +20,7 @@ from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.traffic_routes import TrafficRoutes from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT @@ -31,6 +32,7 @@ from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest +from aiounifi.models.traffic_route import TrafficRoute, TrafficRouteSaveRequest from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest @@ -170,6 +172,16 @@ async def async_traffic_rule_control_fn( await hub.api.traffic_rules.update() +async def async_traffic_route_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control traffic route state.""" + traffic_route = hub.api.traffic_routes[obj_id].raw + await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) + # Update the traffic routes so the UI is updated appropriately + await hub.api.traffic_routes.update() + + async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" await hub.api.request(WlanEnableRequest.create(obj_id, target)) @@ -263,6 +275,19 @@ class UnifiSwitchEntityDescription( object_fn=lambda api, obj_id: api.traffic_rules[obj_id], unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}", ), + UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute]( + key="Traffic route control", + translation_key="traffic_route_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.traffic_routes, + control_fn=async_traffic_route_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, traffic_route: traffic_route.enabled, + name_fn=lambda traffic_route: traffic_route.description, + object_fn=lambda api, obj_id: api.traffic_routes[obj_id], + unique_id_fn=lambda hub, obj_id: f"traffic_route-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", translation_key="poe_port_control", diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index ed409a6eea045b..ba255bb7f7c0b8 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -45,7 +45,12 @@ async_create_api_client, async_get_devices, ) -from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView +from .views import ( + SnapshotProxyView, + ThumbnailProxyView, + VideoEventProxyView, + VideoProxyView, +) _LOGGER = logging.getLogger(__name__) @@ -173,6 +178,7 @@ async def _async_setup_entry( data_service.async_setup() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.http.register_view(ThumbnailProxyView(hass)) + hass.http.register_view(SnapshotProxyView(hass)) hass.http.register_view(VideoProxyView(hass)) hass.http.register_view(VideoEventProxyView(hass)) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 31950f8f7e44eb..22af2fb135d158 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -14,7 +14,6 @@ from unifi_discovery import async_console_is_alive import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -36,6 +35,8 @@ async_create_clientsession, async_get_clientsession, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -107,14 +108,14 @@ def __init__(self) -> None: self._discovered_device: dict[str, str] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) return await self._async_discovery_handoff() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8bce183e34a97..78fdf7746deeb8 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -181,7 +181,6 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -191,7 +190,6 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 486a8956e0c788..fcdfe5e85b8ae8 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -7,7 +7,7 @@ from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,13 +71,10 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hass_brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) - unifi_brightness = hass_to_unifi_brightness(hass_brightness) - - _LOGGER.debug("Turning on light with brightness %s", unifi_brightness) - await self.device.set_light(True, unifi_brightness) + _LOGGER.debug("Turning on light") + await self.device.api.set_light_is_led_force_on(self.device.id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" _LOGGER.debug("Turning off light") - await self.device.set_light(False) + await self.device.api.set_light_is_led_force_on(self.device.id, False) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d4877798208a18..018a600f037497 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.2.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.4.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 9bf6ed024f5d71..cc2e1c6a5fc629 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -44,6 +44,34 @@ def async_generate_thumbnail_url( return f"{url}?{urlencode(params)}" +@callback +def async_generate_snapshot_url( + nvr_id: str, + camera_id: str, + timestamp: datetime, + width: int | None = None, + height: int | None = None, +) -> str: + """Generate URL for event thumbnail.""" + + url_format = SnapshotProxyView.url + if TYPE_CHECKING: + assert url_format is not None + url = url_format.format( + nvr_id=nvr_id, + camera_id=camera_id, + timestamp=timestamp.replace(microsecond=0).isoformat(), + ) + + params = {} + if width is not None: + params["width"] = str(width) + if height is not None: + params["height"] = str(height) + + return f"{url}?{urlencode(params)}" + + @callback def async_generate_event_video_url(event: Event) -> str: """Generate URL for event video.""" @@ -188,6 +216,59 @@ async def get( return web.Response(body=thumbnail, content_type="image/jpeg") +class SnapshotProxyView(ProtectProxyView): + """View to proxy snapshots at specified time from UniFi Protect.""" + + url = "/api/unifiprotect/snapshot/{nvr_id}/{camera_id}/{timestamp}" + name = "api:unifiprotect_snapshot" + + async def get( + self, request: web.Request, nvr_id: str, camera_id: str, timestamp: str + ) -> web.Response: + """Get snapshot.""" + + data = self._get_data_or_404(nvr_id) + if isinstance(data, web.Response): + return data + + camera = self._async_get_camera(data, camera_id) + if camera is None: + return _404(f"Invalid camera ID: {camera_id}") + if not camera.can_read_media(data.api.bootstrap.auth_user): + return _403(f"User cannot read media from camera: {camera.id}") + + width: int | str | None = request.query.get("width") + height: int | str | None = request.query.get("height") + + if width is not None: + try: + width = int(width) + except ValueError: + return _400("Invalid width param") + if height is not None: + try: + height = int(height) + except ValueError: + return _400("Invalid height param") + + try: + timestamp_dt = datetime.fromisoformat(timestamp) + except ValueError: + return _400("Invalid timestamp") + + try: + snapshot = await camera.get_snapshot( + width=width, height=height, dt=timestamp_dt + ) + except ClientError as err: + return _404(err) + + if snapshot is None: + return _404("snapshot not found") + + return web.Response(body=snapshot, content_type="image/jpeg") + + class VideoProxyView(ProtectProxyView): """View to proxy video clips from UniFi Protect.""" diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 30d7cacba8e558..a3fec73dca8e93 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -import dataclasses from datetime import timedelta import logging import requests.exceptions import upcloud_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,34 +21,21 @@ async_dispatcher_send, ) -from .const import ( - CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, - DATA_UPCLOUD, - DEFAULT_SCAN_INTERVAL, -) -from .coordinator import UpCloudDataUpdateCoordinator +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] -@dataclasses.dataclass -class UpCloudHassData: - """Home Assistant UpCloud runtime data.""" - - coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( - default_factory=dict - ) - - -def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: +def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str: """Get signal name for updates to a config entry.""" return CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE.format(config_entry.unique_id) async def _async_signal_options_update( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UpCloudConfigEntry ) -> None: """Signal config entry options update.""" async_dispatcher_send( @@ -58,7 +43,7 @@ async def _async_signal_options_update( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Set up the UpCloud config entry.""" manager = upcloud_api.CloudManager( @@ -81,10 +66,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = UpCloudDataUpdateCoordinator( hass, + config_entry=entry, update_interval=update_interval, cloud_manager=manager, username=entry.data[CONF_USERNAME], ) + entry.runtime_data = coordinator # Call the UpCloud API to refresh data await coordinator.async_config_entry_first_refresh() @@ -99,21 +86,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator - # Forward entry setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME]) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index f135eea24b1799..bca313d306fa00 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -4,23 +4,21 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server binary sensor.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudBinarySensor(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudBinarySensor(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index bb988726ba57b2..16adcc51ddf5b8 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -9,16 +9,12 @@ import upcloud_api import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import UpCloudConfigEntry _LOGGER = logging.getLogger(__name__) @@ -92,7 +88,7 @@ def _async_show_form( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, ) -> UpCloudOptionsFlow: """Get options flow.""" return UpCloudOptionsFlow() diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py index a967a43c46ed36..763462c37f467a 100644 --- a/homeassistant/components/upcloud/const.py +++ b/homeassistant/components/upcloud/const.py @@ -3,6 +3,5 @@ from datetime import timedelta DOMAIN = "upcloud" -DATA_UPCLOUD = "data_upcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}" diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index e10128a30e469f..8088b3a72eade3 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -15,6 +15,9 @@ _LOGGER = logging.getLogger(__name__) +type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] + + class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] ): @@ -24,17 +27,22 @@ def __init__( self, hass: HomeAssistant, *, + config_entry: UpCloudConfigEntry, cloud_manager: upcloud_api.CloudManager, update_interval: timedelta, username: str, ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval + hass, + _LOGGER, + config_entry=config_entry, + name=f"{username}@UpCloud", + update_interval=update_interval, ) self.cloud_manager = cloud_manager - async def async_update_config(self, config_entry: ConfigEntry) -> None: + async def async_update_config(self, config_entry: UpCloudConfigEntry) -> None: """Handle config update.""" self.update_interval = timedelta( seconds=config_entry.options[CONF_SCAN_INTERVAL] diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index c64ca7be2eadfb..1ff5374bcafa86 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any import upcloud_api @@ -12,9 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import UpCloudDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator ATTR_CORE_NUMBER = "core_number" ATTR_HOSTNAME = "hostname" @@ -33,11 +30,12 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def __init__( self, - coordinator: UpCloudDataUpdateCoordinator, + config_entry: UpCloudConfigEntry, uuid: str, ) -> None: """Initialize the UpCloud server entity.""" - super().__init__(coordinator) + super().__init__(config_entry.runtime_data) + self.config_entry = config_entry self.uuid = uuid @property @@ -95,13 +93,11 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - assert self.coordinator.config_entry is not None + assert self.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", model="Control Panel", entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") - }, + identifiers={(DOMAIN, f"{self.config_entry.data[CONF_USERNAME]}@hub")}, manufacturer="UpCloud Ltd", ) diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 7495357ca9e893..97c08b1918835f 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -3,13 +3,12 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, STATE_OFF +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity SIGNAL_UPDATE_UPCLOUD = "upcloud_update" @@ -17,12 +16,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server switch.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudSwitch(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudSwitch(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 8ef9f44237f8d7..a2ecd494920a7d 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -68,8 +68,8 @@ class UpdateDeviceClass(StrEnum): "ATTR_VERSION", "DEVICE_CLASSES_SCHEMA", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "SERVICE_INSTALL", "SERVICE_SKIP", "UpdateDeviceClass", diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 214521ee9c0c67..aacb7538b61121 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -49,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # Register device discovered-callback. device_discovered_event = asyncio.Event() - discovery_info: ssdp.SsdpServiceInfo | None = None + discovery_info: SsdpServiceInfo | None = None async def device_discovered( - headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + headers: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: if change == ssdp.SsdpChange.BYEBYE: return diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 41e481fa58c0e1..95fd1ff0ea5627 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -18,6 +17,12 @@ OptionsFlow, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -37,17 +42,17 @@ from .device import async_get_mac_address_from_host, get_preferred_location -def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: +def _friendly_name_from_discovery(discovery_info: SsdpServiceInfo) -> str: """Extract user-friendly name from discovery.""" return cast( str, - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or discovery_info.ssdp_headers.get("_host", ""), ) -def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return bool( discovery_info.ssdp_udn @@ -59,7 +64,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: async def _async_discovered_igd_devices( hass: HomeAssistant, -) -> list[ssdp.SsdpServiceInfo]: +) -> list[SsdpServiceInfo]: """Discovery IGD devices.""" return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 @@ -76,10 +81,10 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) -def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_igd_device(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is a complete IGD device.""" root_device_info = discovery_info.upnp - return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + return root_device_info.get(ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @@ -167,7 +172,7 @@ async def async_step_user( ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UPnP/IGD device. diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 08e0be2d7121ef..0ca103300dad2b 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.42.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 4517501bf433c0..ec65143b984384 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations -from collections.abc import Coroutine, Sequence +from collections.abc import Callable, Coroutine, Sequence import dataclasses +from datetime import datetime, timedelta import fnmatch +from functools import partial import logging import os import sys @@ -24,9 +26,16 @@ HomeAssistant, callback as hass_callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -39,13 +48,16 @@ _LOGGER = logging.getLogger(__name__) -REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] + +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) +REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ + "USBCallbackMatcher", "async_is_plugged_in", + "async_register_port_event_callback", "async_register_scan_request_callback", - "USBCallbackMatcher", - "UsbServiceInfo", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -76,6 +88,15 @@ def async_register_initial_scan_callback( return discovery.async_register_initial_scan_callback(callback) +@hass_callback +def async_register_port_event_callback( + hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when a USB device is connected or disconnected.""" + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_port_event_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -99,21 +120,33 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo usb_discovery: USBDiscovery = hass.data[DOMAIN] return any( - _is_matching(USBDevice(*device_tuple), matcher) - for device_tuple in usb_discovery.seen + _is_matching( + USBDevice( + device=device, + vid=vid, + pid=pid, + serial_number=serial_number, + manufacturer=manufacturer, + description=description, + ), + matcher, + ) + for ( + device, + vid, + pid, + serial_number, + manufacturer, + description, + ) in usb_discovery.seen ) -@dataclasses.dataclass(slots=True) -class UsbServiceInfo(BaseServiceInfo): - """Prepared info from usb entries.""" - - device: str - vid: str - pid: str - serial_number: str | None - manufacturer: str | None - description: str | None +_DEPRECATED_UsbServiceInfo = DeprecatedConstant( + _UsbServiceInfo, + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + "2026.2", +) @overload @@ -225,10 +258,14 @@ def __init__( self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] + self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() + self._last_processed_devices: set[USBDevice] = set() async def async_setup(self) -> None: """Set up USB Discovery.""" - await self._async_start_monitor() + if await self._async_supports_monitoring(): + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -242,26 +279,54 @@ def async_stop(self, event: Event) -> None: if self._request_debouncer: self._request_debouncer.async_shutdown() + async def _async_supports_monitoring(self) -> bool: + info = await system_info.async_get_system_info(self.hass) + return not info.get("docker") + async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" + """Start monitoring hardware.""" + if not await self._async_start_monitor_udev(): + _LOGGER.info( + "Falling back to periodic filesystem polling for development, libudev " + "is not present" + ) + self._async_start_monitor_polling() + + @hass_callback + def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling (for development only!).""" + + async def _scan(event_time: datetime) -> None: + await self._async_scan_serial() + + stop_callback = async_track_time_interval( + self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD + ) + + @hass_callback + def _stop_polling(event: Event) -> None: + stop_callback() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) + + async def _async_start_monitor_udev(self) -> bool: + """Start monitoring hardware with pyudev. Returns True if successful.""" if not sys.platform.startswith("linux"): - return - info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): - return + return False if not ( observer := await self.hass.async_add_executor_job( self._get_monitor_observer ) ): - return + return False def _stop_observer(event: Event) -> None: observer.stop() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) self.observer_active = True + return True def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. @@ -290,20 +355,23 @@ def _get_monitor_observer(self) -> MonitorObserver | None: return None observer = MonitorObserver( - monitor, callback=self._device_discovered, name="usb-observer" + monitor, callback=self._device_event, name="usb-observer" ) observer.start() return observer - def _device_discovered(self, device: Device) -> None: - """Call when the observer discovers a new usb tty device.""" - if device.action != "add": + def _device_event(self, device: Device) -> None: + """Call when the observer receives a USB device event.""" + if device.action not in ("add", "remove"): return - _LOGGER.debug( - "Discovered Device at path: %s, triggering scan serial", - device.device_path, + + _LOGGER.info( + "Received a udev device event %r for %s, triggering scan", + device.action, + device.device_node, ) + self.hass.create_task(self._async_scan()) @hass_callback @@ -340,6 +408,20 @@ def _async_remove_callback() -> None: return _async_remove_callback + @hass_callback + def async_register_port_event_callback( + self, + callback: PORT_EVENT_CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register a port event callback.""" + self._port_event_callbacks.add(callback) + + @hass_callback + def _async_remove_callback() -> None: + self._port_event_callbacks.discard(callback) + + return _async_remove_callback + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) @@ -352,7 +434,7 @@ async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: if not matched: return - service_info: UsbServiceInfo | None = None + service_info: _UsbServiceInfo | None = None sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) most_matched_fields = len(sorted_by_most_targeted[0]) @@ -364,7 +446,7 @@ async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: break if service_info is None: - service_info = UsbServiceInfo( + service_info = _UsbServiceInfo( device=await self.hass.async_add_executor_job( get_serial_by_id, device.device ), @@ -384,11 +466,11 @@ async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" - usb_devices = [ + usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None - ] + } # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -399,7 +481,7 @@ async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = [ + usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -407,7 +489,22 @@ async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: dev.serial_number in silabs_serials and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) - ] + } + + added_devices = usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - usb_devices + self._last_processed_devices = usb_devices + + _LOGGER.debug( + "Added devices: %r, removed devices: %r", added_devices, removed_devices + ) + + if added_devices or removed_devices: + for callback in self._port_event_callbacks.copy(): + try: + callback(added_devices, removed_devices) + except Exception: + _LOGGER.exception("Error in USB port event callback") for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) @@ -457,3 +554,11 @@ async def websocket_usb_scan( if not usb_discovery.observer_active: await usb_discovery.async_request_scan() connection.send_result(msg["id"]) + + +# 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/usb/models.py b/homeassistant/components/usb/models.py index efc5b11c26e826..11eccd9cd9b533 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -5,7 +5,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(slots=True, frozen=True, kw_only=True) class USBDevice: """A usb device.""" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 46e35bb3e1108e..0cafda82786c95 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ def battery_icon(self) -> str: @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ def fan_speed_list(self) -> list[str]: def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,6 +369,19 @@ def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index ad1c35a124b5e6..41b8730eeb0855 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -58,6 +58,21 @@ async def velbus_scan_task( raise PlatformNotReady( f"Connection error while connecting to Velbus {entry_id}: {ex}" ) from ex + # create all modules + dev_reg = dr.async_get(hass) + for module in controller.get_modules().values(): + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={ + (DOMAIN, str(module.get_addresses()[0])), + }, + manufacturer="Velleman", + model=module.get_type_name(), + model_id=str(module.get_type()), + name=f"{module.get_name()} ({module.get_type_name()})", + sw_version=module.get_sw_version(), + serial_number=module.get_serial(), + ) def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 26e2fafabbc0c0..9e99b2631d4133 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -8,9 +8,9 @@ from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol -from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.util import slugify from .const import DOMAIN @@ -69,9 +69,7 @@ async def async_step_user( errors=self._errors, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" await self.async_set_unique_id( f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 2d9f6e98a4cd79..b40f64e8607936 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -11,6 +11,7 @@ DOMAIN: Final = "velbus" +CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 65f8a1d8d314f7..07dac78b6f103f 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -14,32 +14,57 @@ from .const import DOMAIN +# device identifiers for modules +# (DOMAIN, module_address) + +# device identifiers for channels that are subdevices of a module +# (DOMAIN, f"{module_address}-{channel_number}") + class VelbusEntity(Entity): """Representation of a Velbus entity.""" + _attr_has_entity_name = True _attr_should_poll: bool = False def __init__(self, channel: VelbusChannel) -> None: """Initialize a Velbus entity.""" self._channel = channel + self._module_adress = str(channel.get_module_address()) self._attr_name = channel.get_name() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, str(channel.get_module_address())), + (DOMAIN, self._get_identifier()), }, manufacturer="Velleman", model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), name=channel.get_full_name(), sw_version=channel.get_module_sw_version(), + serial_number=channel.get_module_serial(), ) - serial = channel.get_module_serial() or str(channel.get_module_address()) + if self._channel.is_sub_device(): + self._attr_device_info["via_device"] = ( + DOMAIN, + self._module_adress, + ) + serial = channel.get_module_serial() or self._module_adress self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + def _get_identifier(self) -> str: + """Return the identifier of the entity.""" + if not self._channel.is_sub_device(): + return self._module_adress + return f"{self._module_adress}-{self._channel.get_channel_number()}" + async def async_added_to_hass(self) -> None: """Add listener for state changes.""" self._channel.on_status_update(self._on_update) + async def async_will_remove_from_hass(self) -> None: + """Remove listener for state changes.""" + self._channel.remove_on_status_update(self._on_update) + async def _on_update(self) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 1adf52a8198609..c134095c2ff2da 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -122,19 +122,14 @@ def is_on(self) -> bool: @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", "slow" - elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", "fast" - else: - attr, *args = "set_led_state", "on" + if (flash := ATTR_FLASH in kwargs) and kwargs[ATTR_FLASH] == FLASH_LONG: + await self._channel.set_led_state("slow") + elif flash and kwargs[ATTR_FLASH] == FLASH_SHORT: + await self._channel.set_led_state("fast") else: - attr, *args = "set_led_state", "on" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("on") @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" - attr, *args = "set_led_state", "off" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("off") diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 94c823888b7283..7a2354a7283821 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.12.3"], + "requirements": ["velbus-aio==2025.1.0"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 477b6768e7153c..0ad3e3ce485b90 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -17,16 +17,13 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: todo + entity-event-setup: done entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done - unique-config-entry: - status: todo - comment: | - Manual step does not generate an unique-id + unique-config-entry: done # Silver action-exceptions: todo @@ -41,7 +38,7 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 3f0b1bd6cdb923..765c5a0f6747b5 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -9,15 +9,19 @@ import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: from . import VelbusConfigEntry from .const import ( + CONF_CONFIG_ENTRY, CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, @@ -32,6 +36,7 @@ def setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: + """Check the config_entry for a specific interface.""" for config_entry in hass.config_entries.async_entries(DOMAIN): if "port" in config_entry.data and config_entry.data["port"] == interface: return config_entry.entry_id @@ -39,51 +44,71 @@ def check_entry_id(interface: str) -> str: "The interface provided is not defined as a port in a Velbus integration" ) - def get_config_entry(interface: str) -> VelbusConfigEntry | None: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry - return None + async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: + """Get the config entry for this service call.""" + if CONF_CONFIG_ENTRY in call.data: + entry_id = call.data[CONF_CONFIG_ENTRY] + elif CONF_INTERFACE in call.data: + # Deprecated in 2025.2, to remove in 2025.8 + async_create_issue( + hass, + DOMAIN, + "deprecated_interface_parameter", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_interface_parameter", + ) + entry_id = call.data[CONF_INTERFACE] + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry async def scan(call: ServiceCall) -> None: """Handle a scan service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.scan() + entry = await get_config_entry(call) + await entry.runtime_data.controller.scan() async def syn_clock(call: ServiceCall) -> None: """Handle a sync clock service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.sync_clock() + entry = await get_config_entry(call) + await entry.runtime_data.controller.sync_clock() async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - memo_text = call.data[CONF_MEMO_TEXT] - module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) - if module: - await module.set_memo_text(memo_text.async_render()) + entry = await get_config_entry(call) + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if not module: + raise ServiceValidationError("Module not found") + await module.set_memo_text(memo_text.async_render()) async def clear_cache(call: ServiceCall) -> None: """Handle a clear cache service call.""" - # clear the cache + entry = await get_config_entry(call) with suppress(FileNotFoundError): if call.data.get(CONF_ADDRESS): await hass.async_add_executor_job( os.unlink, hass.config.path( STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", ), ) else: await hass.async_add_executor_job( shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), ) # call a scan to repopulate await scan(call) @@ -92,28 +117,73 @@ async def clear_cache(call: ServiceCall) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SYNC, syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), ), ) @@ -121,12 +191,26 @@ async def clear_cache(call: ServiceCall) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index e3ecc3556f01b0..398869136923d3 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,29 +1,38 @@ sync_clock: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus scan: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus clear_cache: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: false selector: @@ -34,11 +43,14 @@ clear_cache: set_memo_text: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: true selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index be1d992056eee9..90938a6c1d234a 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -20,6 +20,12 @@ "exceptions": { "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } }, "services": { @@ -30,6 +36,10 @@ "interface": { "name": "Interface", "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + }, + "config_entry": { + "name": "Config entry", + "description": "The config entry of the velbus integration" } } }, @@ -40,6 +50,10 @@ "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" } } }, @@ -51,6 +65,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page." @@ -65,6 +83,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page." @@ -75,5 +97,11 @@ } } } + }, + "issues": { + "deprecated_interface_parameter": { + "title": "Deprecated 'interface' parameter", + "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + } } } diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index f4bfa13b4d53ca..fba023f7638e78 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Velux integration.""" +from typing import Any + from pyvlx import PyVLX, PyVLXException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -17,9 +21,31 @@ ) +async def _check_connection(host: str, password: str) -> dict[str, Any]: + """Check if we can connect to the Velux bridge.""" + pyvlx = PyVLX(host=host, password=password) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + LOGGER.debug("Cannot connect: %s", err) + return {"base": "cannot_connect"} + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + return {"base": "unknown"} + + return {} + + class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for velux.""" + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.discovery_data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -28,28 +54,78 @@ async def async_step_user( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - - pyvlx = PyVLX( - host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + errors = await _check_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] ) - try: - await pyvlx.connect() - await pyvlx.disconnect() - except (PyVLXException, ConnectionError) as err: - errors["base"] = "cannot_connect" - LOGGER.debug("Cannot connect: %s", err) - except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", err) - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( step_id="user", - data_schema=data_schema, + data_schema=USER_SCHEMA, + errors=errors, + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery by DHCP.""" + # The hostname ends with the last 4 digits of the device MAC address. + self.discovery_data[CONF_HOST] = discovery_info.ip + self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace( + "LAN_", "" + ) + + await self.async_set_unique_id(self.discovery_data[CONF_NAME]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_data[CONF_HOST]} + ) + + # Abort if config_entry already exists without unigue_id configured. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == self.discovery_data[CONF_HOST] + and entry.unique_id is None + and entry.state is ConfigEntryState.LOADED + ): + self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=self.discovery_data[CONF_NAME], + data={**entry.data, **self.discovery_data}, + ) + return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare configuration for a discovered Velux device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await _check_connection( + self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=self.discovery_data[CONF_NAME], + data={**self.discovery_data, **user_input}, + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), errors=errors, + description_placeholders={ + "name": self.discovery_data[CONF_NAME], + "host": self.discovery_data[CONF_HOST], + }, ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 053b7fcc5943e7..cb21fef299df29 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,8 +1,14 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], "config_flow": true, + "dhcp": [ + { + "hostname": "velux_klf*", + "macaddress": "646184*" + } + ], "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 5b7b459a3f76c1..0cf578732fbae0 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -2,11 +2,16 @@ "config": { "step": { "user": { - "title": "Setup Velux", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "discovery_confirm": { + "description": "Please enter the password for {name} ({host})", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json index 3bfb58f810412a..dcb8f6fc3a2ffa 100644 --- a/homeassistant/components/vera/strings.json +++ b/homeassistant/components/vera/strings.json @@ -8,8 +8,8 @@ "user": { "data": { "vera_controller_url": "Controller URL", - "lights": "Vera switch device ids to treat as lights in Home Assistant.", - "exclude": "Vera device ids to exclude from Home Assistant." + "lights": "Vera switch device IDs to treat as lights in Home Assistant.", + "exclude": "Vera device IDs to exclude from Home Assistant." }, "data_description": { "vera_controller_url": "It should look like this: http://192.168.1.161:3480" @@ -21,7 +21,7 @@ "step": { "init": { "title": "Vera controller options", - "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.", + "description": "See the Vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here require a restart of the Home Assistant server. To clear values, provide a space.", "data": { "lights": "[%key:component::vera::config::step::user::data::lights%]", "exclude": "[%key:component::vera::config::step::user::data::exclude%]" diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 8e8b774498832f..240a793f5187bc 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -9,21 +9,25 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.dispatcher import async_dispatcher_send -from .common import async_process_devices +from .common import async_generate_device_list from .const import ( DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, - VS_LIGHTS, VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, ) from .coordinator import VeSyncDataCoordinator -PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) @@ -43,10 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.error("Unable to login to the VeSync server") return False - device_dict = await async_process_devices(hass, manager) - - forward_setups = hass.config_entries.async_forward_entry_setups - hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager @@ -55,83 +55,25 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator - switches = hass.data[DOMAIN][VS_SWITCHES] = [] - fans = hass.data[DOMAIN][VS_FANS] = [] - lights = hass.data[DOMAIN][VS_LIGHTS] = [] - sensors = hass.data[DOMAIN][VS_SENSORS] = [] - platforms = [] - - if device_dict[VS_SWITCHES]: - switches.extend(device_dict[VS_SWITCHES]) - platforms.append(Platform.SWITCH) - - if device_dict[VS_FANS]: - fans.extend(device_dict[VS_FANS]) - platforms.append(Platform.FAN) + hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager) - if device_dict[VS_LIGHTS]: - lights.extend(device_dict[VS_LIGHTS]) - platforms.append(Platform.LIGHT) - - if device_dict[VS_SENSORS]: - sensors.extend(device_dict[VS_SENSORS]) - platforms.append(Platform.SENSOR) - - await hass.config_entries.async_forward_entry_setups(config_entry, platforms) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] - switches = hass.data[DOMAIN][VS_SWITCHES] - fans = hass.data[DOMAIN][VS_FANS] - lights = hass.data[DOMAIN][VS_LIGHTS] - sensors = hass.data[DOMAIN][VS_SENSORS] - - dev_dict = await async_process_devices(hass, manager) - switch_devs = dev_dict.get(VS_SWITCHES, []) - fan_devs = dev_dict.get(VS_FANS, []) - light_devs = dev_dict.get(VS_LIGHTS, []) - sensor_devs = dev_dict.get(VS_SENSORS, []) - - switch_set = set(switch_devs) - new_switches = list(switch_set.difference(switches)) - if new_switches and switches: - switches.extend(new_switches) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SWITCHES), new_switches) - return - if new_switches and not switches: - switches.extend(new_switches) - hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH])) - - fan_set = set(fan_devs) - new_fans = list(fan_set.difference(fans)) - if new_fans and fans: - fans.extend(new_fans) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans) - return - if new_fans and not fans: - fans.extend(new_fans) - hass.async_create_task(forward_setups(config_entry, [Platform.FAN])) - - light_set = set(light_devs) - new_lights = list(light_set.difference(lights)) - if new_lights and lights: - lights.extend(new_lights) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights) - return - if new_lights and not lights: - lights.extend(new_lights) - hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT])) - - sensor_set = set(sensor_devs) - new_sensors = list(sensor_set.difference(sensors)) - if new_sensors and sensors: - sensors.extend(new_sensors) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SENSORS), new_sensors) + devices = hass.data[DOMAIN][VS_DEVICES] + + new_devices = await async_generate_device_list(hass, manager) + + device_set = set(new_devices) + new_devices = list(device_set.difference(devices)) + if new_devices and devices: + devices.extend(new_devices) + async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices) return - if new_sensors and not sensors: - sensors.extend(new_sensors) - hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR])) + if new_devices and not devices: + devices.extend(new_devices) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery @@ -142,18 +84,8 @@ async def async_new_device_discovery(service: ServiceCall) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - in_use_platforms = [] - if hass.data[DOMAIN][VS_SWITCHES]: - in_use_platforms.append(Platform.SWITCH) - if hass.data[DOMAIN][VS_FANS]: - in_use_platforms.append(Platform.FAN) - if hass.data[DOMAIN][VS_LIGHTS]: - in_use_platforms.append(Platform.LIGHT) - if hass.data[DOMAIN][VS_SENSORS]: - in_use_platforms.append(Platform.SENSOR) - unload_ok = await hass.config_entries.async_unload_platforms( - entry, in_use_platforms - ) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 5f7b2a3a29e748..c51b6a913d3c1d 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -2,43 +2,33 @@ import logging -from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES +from pyvesync import VeSync +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.core import HomeAssistant + +from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) -async def async_process_devices(hass, manager): +async def async_generate_device_list( + hass: HomeAssistant, manager: VeSync +) -> list[VeSyncBaseDevice]: """Assign devices to proper component.""" - devices = {} - devices[VS_SWITCHES] = [] - devices[VS_FANS] = [] - devices[VS_LIGHTS] = [] - devices[VS_SENSORS] = [] + devices: list[VeSyncBaseDevice] = [] await hass.async_add_executor_job(manager.update) - if manager.fans: - devices[VS_FANS].extend(manager.fans) - # Expose fan sensors separately - devices[VS_SENSORS].extend(manager.fans) - _LOGGER.debug("%d VeSync fans found", len(manager.fans)) - - if manager.bulbs: - devices[VS_LIGHTS].extend(manager.bulbs) - _LOGGER.debug("%d VeSync lights found", len(manager.bulbs)) - - if manager.outlets: - devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' voltage, power & energy usage as separate sensors - devices[VS_SENSORS].extend(manager.outlets) - _LOGGER.debug("%d VeSync outlets found", len(manager.outlets)) - - if manager.switches: - for switch in manager.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) - _LOGGER.debug("%d VeSync switches found", len(manager.switches)) + devices.extend(manager.fans) + devices.extend(manager.bulbs) + devices.extend(manager.outlets) + devices.extend(manager.switches) return devices + + +def is_humidifier(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a humidifier.""" + + return isinstance(device, VeSyncHumidifierDevice) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2a8c572234029e..841185e4308f96 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,5 +1,7 @@ """Constants for VeSync Component.""" +from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S + DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" @@ -17,13 +19,18 @@ Using 30 seconds interval gives 8640 for 3 devices which exceeds the quota of 7700. """ - -VS_SWITCHES = "switches" -VS_FANS = "fans" -VS_LIGHTS = "lights" -VS_SENSORS = "sensors" +VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_NUMBERS = "numbers" + +VS_HUMIDIFIER_MODE_AUTO = "auto" +VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" +VS_HUMIDIFIER_MODE_MANUAL = "manual" +VS_HUMIDIFIER_MODE_SLEEP = "sleep" + +VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S +"""Humidifier device types""" DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", @@ -57,6 +64,7 @@ "Core300S": "Core300S", "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S + "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S "Core400S": "Core400S", "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index 68c21a871abc35..3aa7b008cc5997 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -1,11 +1,8 @@ """Common entity for VeSync Component.""" -from typing import Any - from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -50,21 +47,3 @@ def device_info(self) -> DeviceInfo: manufacturer="VeSync", sw_version=self.device.current_firm_version, ) - - -class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): - """Base class for VeSync Device Representations.""" - - @property - def details(self): - """Provide access to the device details dictionary.""" - return self.device.details - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" - - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 95404a921e879f..ba1880f2492950 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -24,11 +24,11 @@ DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, ) from .coordinator import VeSyncDataCoordinator -from .entity import VeSyncDevice +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -74,10 +74,10 @@ def discover(devices): _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -86,21 +86,17 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" - entities = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan": - entities.append(VeSyncFanHA(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue + """Check if device is fan and add entity.""" + entities = [ + VeSyncFanHA(dev, coordinator) + for dev in devices + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" + ] async_add_entities(entities, update_before_add=True) -class VeSyncFanHA(VeSyncDevice, FanEntity): +class VeSyncFanHA(VeSyncBaseEntity, FanEntity): """Representation of a VeSync fan.""" _attr_supported_features = ( @@ -112,11 +108,18 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): _attr_name = None _attr_translation_key = "vesync" - def __init__(self, fan, coordinator: VeSyncDataCoordinator) -> None: + def __init__( + self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync fan device.""" super().__init__(fan, coordinator) self.smartfan = fan + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + @property def percentage(self) -> int | None: """Return the current speed.""" @@ -229,3 +232,7 @@ def turn_on( if percentage is None: percentage = 50 self.set_percentage(percentage) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py new file mode 100644 index 00000000000000..9c54afdfb82570 --- /dev/null +++ b/homeassistant/components/vesync/humidifier.py @@ -0,0 +1,165 @@ +"""Support for VeSync humidifiers.""" + +import logging +from typing import Any + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + MODE_AUTO, + MODE_NORMAL, + MODE_SLEEP, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +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 AddEntitiesCallback + +from .common import is_humidifier +from .const import ( + DOMAIN, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + VeSyncHumidifierDevice, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +MIN_HUMIDITY = 30 +MAX_HUMIDITY = 80 + +VS_TO_HA_ATTRIBUTES = {ATTR_HUMIDITY: "current_humidity"} + +VS_TO_HA_MODE_MAP = { + VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL, + VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP, +} + +HA_TO_VS_MODE_MAP = {v: k for k, v in VS_TO_HA_MODE_MAP.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VeSync humidifier platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add humidifier entities.""" + async_add_entities( + VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev) + ) + + +def _get_ha_mode(vs_mode: str) -> str | None: + ha_mode = VS_TO_HA_MODE_MAP.get(vs_mode) + if ha_mode is None: + _LOGGER.warning("Unknown mode '%s'", vs_mode) + return ha_mode + + +def _get_vs_mode(ha_mode: str) -> str | None: + return HA_TO_VS_MODE_MAP.get(ha_mode) + + +class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): + """Representation of a VeSync humidifier.""" + + # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name + _attr_name = None + + _attr_max_humidity = MAX_HUMIDITY + _attr_min_humidity = MIN_HUMIDITY + _attr_supported_features = HumidifierEntityFeature.MODES + + device: VeSyncHumidifierDevice + + @property + def available_modes(self) -> list[str]: + """Return the available mist modes.""" + return [ + ha_mode + for ha_mode in (_get_ha_mode(vs_mode) for vs_mode in self.device.mist_modes) + if ha_mode + ] + + @property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return self.device.auto_humidity + + @property + def mode(self) -> str | None: + """Get the current preset mode.""" + return _get_ha_mode(self.device.mode) + + def set_humidity(self, humidity: int) -> None: + """Set the target humidity of the device.""" + if not self.device.set_humidity(humidity): + raise HomeAssistantError( + f"An error occurred while setting humidity {humidity}." + ) + + def set_mode(self, mode: str) -> None: + """Set the mode of the device.""" + if mode not in self.available_modes: + raise HomeAssistantError( + "{mode} is not one of the valid available modes: {self.available_modes}" + ) + if not self.device.set_humidity_mode(_get_vs_mode(mode)): + raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 4deb250bd43cc1..40f68986145070 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -17,9 +17,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator -from .entity import VeSyncDevice +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) MAX_MIREDS = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds @@ -41,10 +41,10 @@ def discover(devices): _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -53,27 +53,27 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" - entities: list[VeSyncBaseLight] = [] + """Check if device is a light and add entity.""" + entities: list[VeSyncBaseLightHA] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): entities.append(VeSyncDimmableLightHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) - else: - _LOGGER.debug( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue async_add_entities(entities, update_before_add=True) -class VeSyncBaseLight(VeSyncDevice, LightEntity): +class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity): """Base class for VeSync Light Devices Representations.""" _attr_name = None + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + @property def brightness(self) -> int: """Get light brightness.""" @@ -139,15 +139,19 @@ def turn_on(self, **kwargs: Any) -> None: # send turn_on command to pyvesync api self.device.turn_on() + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() + -class VeSyncDimmableLightHA(VeSyncBaseLight, LightEntity): +class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity): """Representation of a VeSync dimmable light device.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} -class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity): +class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): """Representation of a VeSync Tunable White Light device.""" _attr_color_mode = ColorMode.COLOR_TEMP diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index a706b2157ba816..81fb1a764f0f7e 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,13 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], + "codeowners": [ + "@markperdue", + "@webdjoe", + "@thegardenmonkey", + "@cdnninja", + "@iprak" + ], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py new file mode 100644 index 00000000000000..3c43cce28cf90c --- /dev/null +++ b/homeassistant/components/vesync/number.py @@ -0,0 +1,114 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import is_humidifier +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncNumberEntityDescription(NumberEntityDescription): + """Class to describe a Vesync number entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + + +NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ + VeSyncNumberEntityDescription( + key="mist_level", + translation_key="mist_level", + native_min_value=1, + native_max_value=9, + native_step=1, + mode=NumberMode.SLIDER, + exists_fn=is_humidifier, + set_value_fn=lambda device, value: device.set_mist_level(value), + value_fn=lambda device: device.mist_level, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add number entities.""" + + async_add_entities( + VeSyncNumberEntity(dev, description, coordinator) + for dev in devices + for description in NUMBER_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncNumberEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncNumberEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync number device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def native_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.value_fn(self.device) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.device, value + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f283e3a3c0a326..bf52050d745002 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -7,9 +7,6 @@ import logging from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.vesyncfan import VeSyncAirBypass -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch from homeassistant.components.sensor import ( SensorDeviceClass, @@ -31,13 +28,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from .common import is_humidifier from .const import ( DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_SENSORS, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -49,14 +47,10 @@ class VeSyncSensorEntityDescription(SensorEntityDescription): """Describe VeSync sensor entity.""" - value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] + value_fn: Callable[[VeSyncBaseDevice], StateType] - exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( - lambda _: True - ) - update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( - lambda _: None - ) + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None def update_energy(device): @@ -186,6 +180,14 @@ def ha_dev_type(device): update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), + VeSyncSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["humidity"], + exists_fn=is_humidifier, + ), ) @@ -204,16 +206,16 @@ def discover(devices): _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities, + async_add_entities: AddEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Check if device is online and add entity.""" @@ -236,9 +238,9 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): def __init__( self, - device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, + device: VeSyncBaseDevice, description: VeSyncSensorEntityDescription, - coordinator, + coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the VeSync outlet device.""" super().__init__(device, coordinator) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index b6e4e2fd957fe0..a23fe7936e717e 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,11 @@ "name": "Current voltage" } }, + "number": { + "mist_level": { + "name": "Mist level" + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index eac1d967eee38e..ef8e6c6051f2a5 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -11,9 +11,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator -from .entity import VeSyncDevice +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -33,10 +33,10 @@ def discover(devices): _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -45,23 +45,18 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" + """Check if device is a switch and add entity.""" entities: list[VeSyncBaseSwitch] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": entities.append(VeSyncSwitchHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": entities.append(VeSyncLightSwitch(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue async_add_entities(entities, update_before_add=True) -class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity): +class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): """Base class for VeSync switch Device Representations.""" _attr_name = None @@ -70,11 +65,22 @@ def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self.device.turn_on() + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() + class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): """Representation of a VeSync switch.""" - def __init__(self, plug, coordinator: VeSyncDataCoordinator) -> None: + def __init__( + self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync switch device.""" super().__init__(plug, coordinator) self.smartplug = plug @@ -83,7 +89,9 @@ def __init__(self, plug, coordinator: VeSyncDataCoordinator) -> None: class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): """Handle representation of VeSync Light Switch.""" - def __init__(self, switch, coordinator: VeSyncDataCoordinator) -> None: + def __init__( + self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize Light Switch device class.""" super().__init__(switch, coordinator) self.switch = switch diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 9c331f0e9ecfdd..12d8ba520f11fc 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -146,7 +146,8 @@ async def async_migrate_devices_and_entities( # to `-heating-` if entity_entry.domain == DOMAIN_CLIMATE: unique_id_parts[len(unique_id_parts) - 1] = ( - f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + f"{entity_entry.translation_key}-" + f"{unique_id_parts[len(unique_id_parts) - 1]}" ) entity_new_unique_id = "-".join(unique_id_parts) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 6594e6ec9e47a4..36db8e92cc7759 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -12,11 +12,11 @@ ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_HEATING_TYPE, @@ -109,7 +109,7 @@ async def async_step_reauth_confirm( ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 2d858185b9fedd..11955a94b94fba 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -29,7 +29,11 @@ def __init__( gateway_serial = device_config.getConfig().serial device_id = device_config.getId() - identifier = f"{gateway_serial}_{device_serial.replace("zigbee-", "zigbee_") if device_serial is not None else device_id}" + identifier = ( + f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" + if device_serial is not None + else f"{gateway_serial}_{device_id}" + ) self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 69aa8396fead98..fc18bdbd8da560 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -28,7 +28,7 @@ from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import get_device_serial +from .utils import filter_state, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -143,15 +143,20 @@ def __init__( def update(self) -> None: """Update state of fan.""" + level: str | None = None try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveMode() ) with suppress(PyViCareNotSupportedFeatureError): + level = filter_state(self._api.getVentilationLevel()) + if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: self._attr_percentage = ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram() + ORDERED_NAMED_FAN_SPEEDS, VentilationProgram(level) ) + else: + self._attr_percentage = 0 except RequestConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 9d0f27a863c5ac..52148b1fa3239f 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -84,6 +84,9 @@ }, "compressor_phase": { "default": "mdi:information" + }, + "ventilation_level": { + "default": "mdi:fan" } } }, diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 3386c849f74c52..ba0191c5cd2e3d 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -49,6 +49,7 @@ from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin from .utils import ( + filter_state, get_burners, get_circuits, get_compressors, @@ -796,7 +797,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM translation_key="photovoltaic_status", device_class=SensorDeviceClass.ENUM, options=["ready", "production"], - value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()), + value_getter=lambda api: filter_state(api.getPhotovoltaicStatus()), ), ViCareSensorEntityDescription( key="room_temperature", @@ -812,6 +813,29 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM state_class=SensorStateClass.MEASUREMENT, value_getter=lambda api: api.getHumidity(), ), + ViCareSensorEntityDescription( + key="ventilation_level", + translation_key="ventilation_level", + value_getter=lambda api: filter_state(api.getVentilationLevel().lower()), + device_class=SensorDeviceClass.ENUM, + options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"], + ), + ViCareSensorEntityDescription( + key="ventilation_reason", + translation_key="ventilation_reason", + value_getter=lambda api: api.getVentilationReason().lower(), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "standby", + "permanent", + "schedule", + "sensordriven", + "silent", + "forcedlevelfour", + ], + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -920,10 +944,6 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ) -def _filter_pv_states(state: str) -> str | None: - return None if state in ("nothing", "unknown") else state - - def _build_entities( device_list: list[ViCareDevice], ) -> list[ViCareSensor]: diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 4934507e41c7ff..074c994d4a588a 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -434,6 +434,27 @@ }, "compressor_phase": { "name": "Compressor phase" + }, + "ventilation_level": { + "name": "Ventilation level", + "state": { + "standby": "[%key:common::state::standby%]", + "levelone": "1", + "leveltwo": "2", + "levelthree": "3", + "levelfour": "4" + } + }, + "ventilation_reason": { + "name": "Ventilation reason", + "state": { + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "schedule": "Schedule", + "sensordriven": "Sensor-driven", + "silent": "Silent", + "forcedlevelfour": "Boost" + } } }, "water_heater": { diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 120dad83113010..a2c31df4259c3b 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -128,3 +128,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone except AttributeError as error: _LOGGER.debug("No compressors found: %s", error) return [] + + +def filter_state(state: str) -> str | None: + """Remove invalid states.""" + return None if state in ("nothing", "unknown") else state diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index d3921061d8e946..572f093dfd35af 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -11,7 +11,6 @@ from pyvizio.const import APP_HOME import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( SOURCE_ZEROCONF, @@ -32,6 +31,7 @@ from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address from .const import ( @@ -257,7 +257,7 @@ async def async_step_user( return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index cee0cbb0766fb1..96e758e91f4b8a 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -30,9 +30,9 @@ __all__ = [ "DOMAIN", + "async_remove_config_entry_device", "async_setup_entry", "async_unload_entry", - "async_remove_config_entry_device", ] diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 613d05fc614104..163cb445340730 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -136,16 +136,23 @@ def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice: fw_version = None dev_reg = dr.async_get(self.hass) - voip_id = call_info.caller_ip + if call_info.caller_endpoint is None: + raise RuntimeError("Could not identify VOIP caller") + voip_id = call_info.caller_endpoint.uri voip_device = self.devices.get(voip_id) - if voip_device is not None: - device = dev_reg.async_get(voip_device.device_id) - if device and fw_version and device.sw_version != fw_version: - dev_reg.async_update_device(device.id, sw_version=fw_version) - - return voip_device - + if voip_device is None: + # If we couldn't find the device based on SIP URI, see if we can + # find an old device based on just the host/IP and migrate it + voip_device = self.devices.get(call_info.caller_endpoint.host) + if voip_device is not None: + voip_device.voip_id = voip_id + self.devices[voip_id] = voip_device + dev_reg.async_update_device( + voip_device.device_id, new_identifiers={(DOMAIN, voip_id)} + ) + + # Update device with latest info device = dev_reg.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, voip_id)}, @@ -155,6 +162,10 @@ def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice: sw_version=fw_version, configuration_url=f"http://{call_info.caller_ip}", ) + + if voip_device is not None: + return voip_device + voip_device = self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 7cc58556f3e73b..00b3ab911ae073 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -8,12 +8,12 @@ from pyvolumio import CannotConnectError, Volumio import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -97,7 +97,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 61b5a954389130..d8344cbdeec378 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." + "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", @@ -17,7 +17,7 @@ }, "step": { "auth": { - "description": "Login to your Vulcan Account using mobile app registration page.", + "description": "Log in to your Vulcan Account using mobile app registration page.", "data": { "token": "Token", "region": "Symbol", diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8b3a5bbf33118d..65556668bac200 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -25,12 +25,12 @@ from .models import DetectionResult, WakeWord __all__ = [ - "async_default_entity", - "async_get_wake_word_detection_entity", - "DetectionResult", "DOMAIN", + "DetectionResult", "WakeWord", "WakeWordDetectionEntity", + "async_default_entity", + "async_get_wake_word_detection_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 638bf2974158e8..6782a93541b9e7 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -90,7 +90,7 @@ class WatergateSensorEntityDescription(SensorEntityDescription): WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) ) if data.networking else None @@ -104,7 +104,7 @@ class WatergateSensorEntityDescription(SensorEntityDescription): WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) ) if data.networking else None @@ -158,7 +158,11 @@ class WatergateSensorEntityDescription(SensorEntityDescription): ), WatergateSensorEntityDescription( value_fn=lambda data: ( - PowerSupplyMode(data.state.power_supply.replace("+", "_")) + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) if data.state else None ), diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index cca1789bf7e54a..8f8de694b2dcbe 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity id which provides this information in its state, an entity id with latitude and longitude attributes, or zone friendly name.", + "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity ID which provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name.", "data": { "name": "[%key:common::config_flow::data::name%]", "origin": "Origin", @@ -26,13 +26,13 @@ "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.", "data": { "units": "Units", - "vehicle_type": "Vehicle Type", + "vehicle_type": "Vehicle type", "incl_filter": "Exact streetname which must be part of the selected route", "excl_filter": "Exact streetname which must NOT be part of the selected route", - "realtime": "Realtime Travel Time?", - "avoid_toll_roads": "Avoid Toll Roads?", - "avoid_ferries": "Avoid Ferries?", - "avoid_subscription_roads": "Avoid Roads Needing a Vignette / Subscription?" + "realtime": "Realtime travel time?", + "avoid_toll_roads": "Avoid toll roads?", + "avoid_ferries": "Avoid ferries?", + "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?" } } } @@ -47,8 +47,8 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "region": { @@ -63,8 +63,8 @@ }, "services": { "get_travel_times": { - "name": "Get Travel Times", - "description": "Get route alternatives and travel times between two locations.", + "name": "Get travel times", + "description": "Retrieves route alternatives and travel times between two locations.", "fields": { "origin": { "name": "[%key:component::waze_travel_time::config::step::user::data::origin%]", @@ -76,7 +76,7 @@ }, "region": { "name": "[%key:component::waze_travel_time::config::step::user::data::region%]", - "description": "The region. Controls which waze server is used." + "description": "The region. Controls which Waze server is used." }, "units": { "name": "[%key:component::waze_travel_time::options::step::init::data::units%]", diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 557765795ee88d..50d90c59d37e16 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,10 +8,19 @@ from datetime import timedelta from functools import partial import logging -from typing import Any, Final, Generic, Literal, Required, TypedDict, cast, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) from propcache import cached_property -from typing_extensions import TypeVar import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 34e11f49978bd8..01c4212d99ef23 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -97,14 +97,16 @@ def async_generate_url( ) -> str: """Generate the full URL for a webhook_id.""" return ( - f"{get_url( - hass, - allow_internal=allow_internal, - allow_external=allow_external, - allow_cloud=False, - allow_ip=allow_ip, - prefer_external=prefer_external, - )}" + f"{ + get_url( + hass, + allow_internal=allow_internal, + allow_external=allow_external, + allow_cloud=False, + allow_ip=allow_ip, + prefer_external=prefer_external, + ) + }" f"{async_generate_path(webhook_id)}" ) diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 64f8c684dfac9d..903d6c50a09761 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -45,7 +45,7 @@ async def validate_user_input( raise SchemaFlowError("invalid_auth") from err raise SchemaFlowError("cannot_connect") from err except Fault as fault: - LOGGER.exception(f"Fault {fault.faultCode}: {fault.faultString}") + LOGGER.exception("Fault %s: %s", fault.faultCode, fault.faultString) raise SchemaFlowError("unknown") from fault except ClientConnectionError as err: raise SchemaFlowError("cannot_connect") from err diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 499d0a855188ad..3a3ee8e4c7eea9 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -4,92 +4,53 @@ from contextlib import suppress import logging -from typing import NamedTuple from aiowebostv import WebOsClient, WebOsTvPairError -import voluptuous as vol from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_BUTTON, ATTR_CONFIG_ENTRY_ID, - ATTR_PAYLOAD, - ATTR_SOUND_OUTPUT, - DATA_CONFIG_ENTRY, DATA_HASS_CONFIG, DOMAIN, PLATFORMS, - SERVICE_BUTTON, - SERVICE_COMMAND, - SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) - - -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -BUTTON_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_BUTTON): cv.string}) - -COMMAND_SCHEMA = CALL_SCHEMA.extend( - {vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PAYLOAD): dict} -) - -SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string}) - -SERVICE_TO_METHOD = { - SERVICE_BUTTON: ServiceMethodDetails(method="async_button", schema=BUTTON_SCHEMA), - SERVICE_COMMAND: ServiceMethodDetails( - method="async_command", schema=COMMAND_SCHEMA - ), - SERVICE_SELECT_SOUND_OUTPUT: ServiceMethodDetails( - method="async_select_sound_output", - schema=SOUND_OUTPUT_SCHEMA, - ), -} _LOGGER = logging.getLogger(__name__) +type WebOsTvConfigEntry = ConfigEntry[WebOsClient] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LG WebOS TV platform.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) - hass.data[DOMAIN][DATA_HASS_CONFIG] = config + hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Set the config entry up.""" host = entry.data[CONF_HOST] key = entry.data[CONF_CLIENT_SECRET] # Attempt a connection, but fail gracefully if tv is off for example. - client = WebOsClient(host, key) + entry.runtime_data = client = WebOsClient(host, key) with suppress(*WEBOSTV_EXCEPTIONS): try: await client.connect() @@ -100,18 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Update the stored key without triggering reauth update_client_key(hass, entry, client) - async def async_service_handler(service: ServiceCall) -> None: - method = SERVICE_TO_METHOD[service.service] - data = service.data.copy() - data["method"] = method.method - async_dispatcher_send(hass, DOMAIN, data) - - for service, method in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=method.schema - ) - - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # set up notify platform, no entry support for notify component yet, @@ -119,7 +68,7 @@ async def async_service_handler(service: ServiceCall) -> None: hass.async_create_task( discovery.async_load_platform( hass, - "notify", + Platform.NOTIFY, DOMAIN, { CONF_NAME: entry.title, @@ -129,8 +78,7 @@ async def async_service_handler(service: ServiceCall) -> None: ) ) - if not entry.update_listeners: - entry.async_on_unload(entry.add_update_listener(async_update_options)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" @@ -143,7 +91,7 @@ async def async_on_stop(_event: Event) -> None: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -173,19 +121,12 @@ def update_client_key( hass.config_entries.async_update_entry(entry, data=data) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client = entry.runtime_data await hass_notify.async_reload(hass, DOMAIN) client.clear_state_update_callbacks() await client.disconnect() - # unregister service calls, check if this is the last entry to unload - if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - for service in SERVICE_TO_METHOD: - hass.services.async_remove(DOMAIN, service) - return unload_ok diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 9a5eda7bbf7481..a0ee9f1ac7fc33 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,25 +9,28 @@ from aiowebostv import WebOsTvPairError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) -from . import async_control_connect, update_client_key +from . import async_control_connect from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS from .helpers import async_get_sources DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }, extra=vol.ALLOW_EXTRA, ) @@ -54,15 +57,11 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] - self._name = user_input[CONF_NAME] return await self.async_step_pairing() - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) async def async_step_pairing( self, user_input: dict[str, Any] | None = None @@ -71,13 +70,13 @@ async def async_step_pairing( self._async_abort_entries_match({CONF_HOST: self._host}) self.context["title_placeholders"] = {"name": self._name} - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: @@ -86,24 +85,28 @@ async def async_step_pairing( ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + + if not self._name: + self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" assert discovery_info.ssdp_location host = urlparse(discovery_info.ssdp_location).hostname assert host self._host = host - self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME) + self._name = discovery_info.upnp.get( + ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME + ).replace("[LG]", "LG") - uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + uuid = discovery_info.upnp[ATTR_UPNP_UDN] assert uuid - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) @@ -128,20 +131,56 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: - return self.async_abort(reason="reauth_unsuccessful") + errors["base"] = "cannot_connect" + else: + reauth_entry = self._get_reauth_entry() + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reauth_entry, data=data) + + return self.async_show_form(step_id="reauth_confirm", errors=errors) - reauth_entry = self._get_reauth_entry() - update_client_key(self.hass, reauth_entry, client) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() - return self.async_show_form(step_id="reauth_confirm") + if user_input is not None: + host = user_input[CONF_HOST] + client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) + + try: + client = await async_control_connect(host, client_key) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(client.hello_info["deviceUUID"]) + self._abort_if_unique_id_mismatch(reason="wrong_device") + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reconfigure_entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST) + ): cv.string + } + ), + errors=errors, + ) class OptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index c20060cae9116a..65d964d8fd4ea2 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -9,9 +9,8 @@ DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_CONFIG_ENTRY = "config_entry" DATA_HASS_CONFIG = "hass_config" -DEFAULT_NAME = "LG webOS Smart TV" +DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" ATTR_CONFIG_ENTRY_ID = "entry_id" diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index f16b1cec4f53e3..877c607f9392e4 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -15,7 +15,6 @@ from homeassistant.helpers.typing import ConfigType from . import trigger -from .const import DOMAIN from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -43,8 +42,7 @@ async def async_validate_trigger_config( device_id = config[CONF_DEVICE_ID] try: device = async_get_device_entry_by_device_id(hass, device_id) - if DOMAIN in hass.data: - async_get_client_by_device_entry(hass, device) + async_get_client_by_device_entry(hass, device) except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 1657fb71d26d8d..d5e2dac06dcf46 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -7,11 +7,10 @@ from aiowebostv import WebOsClient from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import DATA_CONFIG_ENTRY, DOMAIN +from . import WebOsTvConfigEntry TO_REDACT = { CONF_CLIENT_SECRET, @@ -25,10 +24,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WebOsTvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + client: WebOsClient = entry.runtime_data client_data = { "is_registered": client.is_registered(), diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index edcfdcfed8b486..63724069f17a9d 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -4,12 +4,13 @@ from aiowebostv import WebOsClient +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import async_control_connect -from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS @callback @@ -55,15 +56,18 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - if client := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id): - break - - if not client: - raise ValueError( - f"Device {device.id} is not from an existing {DOMAIN} config entry" - ) - - return client + entry = hass.config_entries.async_get_entry(config_entry_id) + if entry and entry.domain == DOMAIN: + if entry.state is ConfigEntryState.LOADED: + return entry.runtime_data + + raise ValueError( + f"Device {device.id} is not from a loaded {DOMAIN} config entry" + ) + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) async def async_get_sources(host: str, key: str) -> list[str]: diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 239780e3f018a2..719e3edbf4b9b5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -12,6 +12,7 @@ from typing import Any, Concatenate, cast from aiowebostv import WebOsClient, WebOsTvPairError +import voluptuous as vol from homeassistant import util from homeassistant.components.media_player import ( @@ -21,30 +22,28 @@ MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, -) +from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction +from homeassistant.helpers.typing import VolDictType -from . import update_client_key +from . import WebOsTvConfigEntry, update_client_key from .const import ( + ATTR_BUTTON, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_SOURCES, - DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, + SERVICE_BUTTON, + SERVICE_COMMAND, + SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) from .triggers.turn_on import async_get_turn_on_trigger @@ -68,15 +67,35 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=10) +BUTTON_SCHEMA: VolDictType = {vol.Required(ATTR_BUTTON): cv.string} +COMMAND_SCHEMA: VolDictType = { + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PAYLOAD): dict, +} +SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} + +SERVICES = ( + (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), + (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), + (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: WebOsTvConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the LG webOS Smart TV platform.""" - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] - async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) + platform = entity_platform.async_get_current_platform() + + for service_name, schema, method in SERVICES: + platform.async_register_entity_service(service_name, schema, method) + + async_add_entities([LgWebOSMediaPlayerEntity(entry)]) def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( @@ -113,10 +132,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the webos device.""" self._entry = entry - self._client = client + self._client = entry.runtime_data self._attr_assumed_state = True self._device_name = entry.title self._attr_unique_id = entry.unique_id @@ -142,10 +161,6 @@ async def async_added_to_hass(self) -> None: ) ) - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) - ) - await self._client.register_state_update_callback( self.async_handle_state_update ) @@ -165,19 +180,6 @@ async def async_will_remove_from_hass(self) -> None: """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_signal_handler(self, data: dict[str, Any]) -> None: - """Handle domain-specific signal by calling appropriate method.""" - if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE: - return - - if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: - params = { - key: value - for key, value in data.items() - if key not in ["entity_id", "method"] - } - await getattr(self, data["method"])(**params) - async def async_handle_state_update(self, _client: WebOsClient) -> None: """Update state from WebOsClient.""" self._update_states() @@ -325,7 +327,7 @@ async def async_update(self) -> None: if self._client.is_connected(): return - with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + with suppress(*WEBOSTV_EXCEPTIONS): try: await self._client.connect() except WebOsTvPairError: diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 43320687ce87bc..fde0e6ad6074c6 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -12,10 +12,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import ATTR_CONFIG_ENTRY_ID, WEBOSTV_EXCEPTIONS _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_get_service( hass: HomeAssistant, @@ -27,9 +29,12 @@ async def async_get_service( if discovery_info is None: return None - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][discovery_info[ATTR_CONFIG_ENTRY_ID]] + config_entry = hass.config_entries.async_get_entry( + discovery_info[ATTR_CONFIG_ENTRY_ID] + ) + assert config_entry is not None - return LgWebOSNotificationService(client) + return LgWebOSNotificationService(config_entry.runtime_data) class LgWebOSNotificationService(BaseNotificationService): diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index b6a6a5e99a495a..3a31c20f256f94 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: todo - comment: move actions to entity services + action-setup: done appropriate-polling: done brands: done common-modules: status: exempt comment: The integration does not use common patterns. - config-flow-test-coverage: todo - config-flow: - status: todo - comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, remove name parameter, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log, make reauth flow more graceful + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: status: todo @@ -22,7 +18,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -35,9 +31,9 @@ rules: entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done @@ -68,7 +64,7 @@ rules: icon-translations: status: exempt comment: The only entity can use the device class. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: The integration does not have anything to repair. diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 3ceab5f50a3792..b0786bd06de67d 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -1,15 +1,14 @@ { "config": { - "flow_title": "LG webOS Smart TV", + "flow_title": "{name}", "step": { "user": { - "description": "Turn on TV, fill the following fields and select **Submit**", + "description": "Turn on the TV, fill the host field and select **Submit**", "data": { - "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your webOS TV." + "host": "Hostname or IP address of your LG webOS TV." } }, "pairing": { @@ -19,17 +18,26 @@ "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", "description": "[%key:component::webostv::config::step::pairing::description%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::webostv::config::step::user::data_description::host%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" + "cannot_connect": "Failed to connect, please turn on your TV and try again.", + "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again." }, "abort": { - "error_pairing": "Connected to LG webOS TV but not paired", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this Hostname or IP address." } }, "options": { @@ -39,6 +47,9 @@ "description": "Select enabled sources", "data": { "sources": "Sources list" + }, + "data_description": { + "sources": "List of sources to enable" } } }, diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62f1adc39b97b1..12473c8625580e 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -40,17 +40,17 @@ class ActiveConnection: """Handle an active websocket client connection.""" __slots__ = ( - "logger", + "binary_handlers", + "can_coalesce", + "handlers", "hass", - "send_message", - "user", + "last_id", + "logger", "refresh_token_id", + "send_message", "subscriptions", - "last_id", - "can_coalesce", "supported_features", - "handlers", - "binary_handlers", + "user", ) def __init__( @@ -189,13 +189,13 @@ def async_handle(self, msg: JsonValueType) -> None: if ( # Not using isinstance as we don't care about children # as these are always coming from JSON - type(msg) is not dict # noqa: E721 + type(msg) is not dict or ( not (cur_id := msg.get("id")) - or type(cur_id) is not int # noqa: E721 + or type(cur_id) is not int or cur_id < 0 or not (type_ := msg.get("type")) - or type(type_) is not str # noqa: E721 + or type(type_) is not str ) ): self.logger.error("Received invalid command: %s", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index e7d57aebab611b..8bfa9480ff4f2f 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -63,27 +63,27 @@ class WebSocketAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: """Add connid to websocket log messages.""" assert self.extra is not None - return f'[{self.extra["connid"]}] {msg}', kwargs + return f"[{self.extra['connid']}] {msg}", kwargs class WebSocketHandler: """Handle an active websocket client connection.""" __slots__ = ( - "_hass", - "_loop", - "_request", - "_wsock", - "_handle_task", - "_writer_task", - "_closing", "_authenticated", - "_logger", - "_peak_checker_unsub", + "_closing", "_connection", + "_handle_task", + "_hass", + "_logger", + "_loop", "_message_queue", + "_peak_checker_unsub", "_ready_future", "_release_ready_queue_size", + "_request", + "_writer_task", + "_wsock", ) def __init__(self, hass: HomeAssistant, request: web.Request) -> None: @@ -197,7 +197,7 @@ def _send_message(self, message: str | bytes | dict[str, Any]) -> None: # max pending messages. return - if type(message) is not bytes: # noqa: E721 + if type(message) is not bytes: if isinstance(message, dict): message = message_to_json_bytes(message) elif isinstance(message, str): @@ -490,7 +490,7 @@ async def _async_websocket_command_phase( ) # command_msg_data is always deserialized from JSON as a list - if type(command_msg_data) is not list: # noqa: E721 + if type(command_msg_data) is not list: async_handle_str(command_msg_data) continue diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index a043a3a68455fe..d8d8616c867551 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations +from http import HTTPStatus + +import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -28,12 +32,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + LOGGER.warning("API error: %s (%s)", ex.status, ex.message) + if ex.status in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + token = session.token[CONF_ACCESS_TOKEN] entry.runtime_data = [] # fetch a list of the heat pumps the entry can access try: - discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token) + discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active( + API_URL, token, async_get_clientsession(hass) + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py deleted file mode 100644 index b1f5c0b3eff21b..00000000000000 --- a/homeassistant/components/weheat/api.py +++ /dev/null @@ -1,28 +0,0 @@ -"""API for Weheat bound to Home Assistant OAuth.""" - -from aiohttp import ClientSession -from weheat.abstractions import AbstractAuth - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - -from .const import API_URL - - -class AsyncConfigEntryAuth(AbstractAuth): - """Provide Weheat authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize Weheat auth.""" - super().__init__(websession, host=API_URL) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index ea939227e77e40..1fb8f614a407b2 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -18,6 +18,9 @@ from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index b1a0b5dd4ea6be..2911ebdd49b21f 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -4,10 +4,11 @@ import logging from typing import Any -from weheat.abstractions.user import get_user_id_from_token +from weheat.abstractions.user import async_get_user_id_from_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES @@ -33,8 +34,10 @@ def extra_authorize_data(self) -> dict[str, str]: async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Override the create entry method to change to the step to find the heat pumps.""" # get the user id and use that as unique id for this entry - user_id = await get_user_id_from_token( - API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] + user_id = await async_get_user_id_from_token( + API_URL, + data[CONF_TOKEN][CONF_ACCESS_TOKEN], + async_get_clientsession(self.hass), ) await self.async_set_unique_id(user_id) if self.source != SOURCE_REAUTH: diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index a50e9daec18d1b..4a85380e4a319b 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -47,7 +48,9 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.heat_pump_info = heat_pump - self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) self.session = session @@ -68,19 +71,17 @@ def model(self) -> str: """Return the model of the heat pump.""" return self.heat_pump_info.model - def fetch_data(self) -> HeatPump: - """Get the data from the API.""" + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + try: - self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + await self._heat_pump_data.async_get_status( + self.session.token[CONF_ACCESS_TOKEN] + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error except EXCEPTIONS as error: raise UpdateFailed(error) from error return self._heat_pump_data - - async def _async_update_data(self) -> HeatPump: - """Fetch data from the API.""" - await self.session.async_ensure_token_valid() - - return await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1c6242de29c734..1d60f66afba1f6 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.12.22"] + "requirements": ["weheat==2025.1.15"] } diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml new file mode 100644 index 00000000000000..705efce4421835 --- /dev/null +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions currently available + 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: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: todo + comment: | + There are two servers that are used for this integration. + If the authentication server is unreachable, the user will not pass the configuration step. + If the backend is unreachable, an empty error message is displayed. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions currently available + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters available. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + There is no reconfiguration, as the only configuration step is authentication. + repair-issues: + status: exempt + comment: | + This is a cloud service and apart form reauthentication there are not user repairable issues. + stale-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 3e5d9376c34c84..2d840aec86a374 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -31,6 +31,9 @@ from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index b84518cedf109d..9180164c272dc8 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -291,9 +291,8 @@ def update_from_latest_data(self) -> None: seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if ( - self._attr_native_value is None - or isinstance(self._attr_native_value, datetime) + if self._attr_native_value is None or ( + isinstance(self._attr_native_value, datetime) and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) ): self._attr_native_value = new_timestamp diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 74663d61d8f928..1036e5b1eade66 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -5,9 +5,15 @@ import pywilight -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .const import DOMAIN @@ -53,25 +59,25 @@ def _get_entry(self): return self.async_create_entry(title=self._title, data=data) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered WiLight.""" # Filter out basic information if ( not discovery_info.ssdp_location - or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp + or ATTR_UPNP_MANUFACTURER not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp + or ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp ): return self.async_abort(reason="not_wilight_device") # Filter out non-WiLight devices - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: return self.async_abort(reason="not_wilight_device") host = urlparse(discovery_info.ssdp_location).hostname - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92b01..59c3ed8433f837 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -223,10 +224,13 @@ async def unregister_webhook( "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 71bc0a9aaa8681..92b25389450f89 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -10,10 +10,11 @@ from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant.components import dhcp, onboarding +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from .const import DEFAULT_NAME, DISCOVER_SCAN_TIMEOUT, DOMAIN, WIZ_CONNECT_EXCEPTIONS @@ -38,7 +39,7 @@ def __init__(self) -> None: self._discovered_devices: dict[str, DiscoveredBulb] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = DiscoveredBulb( diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 812a0500d1a38d..2e0b7b1c793530 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -68,7 +69,7 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 2ce58ec9ecaaa9..94deed11c08d39 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -10,12 +10,11 @@ import voluptuous as vol from wmspro.webcontrol import WebControlPro -from homeassistant.components import dhcp -from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_DHCP, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, SUGGESTED_HOST @@ -34,7 +33,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the DHCP discovery step.""" unique_id = format_mac(discovery_info.macaddress) @@ -95,7 +94,7 @@ async def async_step_user( return self.async_abort(reason="already_configured") return self.async_create_entry(title=host, data=user_input) - if self.source == dhcp.DOMAIN: + if self.source == SOURCE_DHCP: discovery_info: DhcpServiceInfo = self.init_data data_values = {CONF_HOST: discovery_info.ip} else: diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index de9cbe694d8995..bb5e6333b8b7ae 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.63"] + "requirements": ["holidays==0.64"] } diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index 5fdcb1a5484c6c..41e7b9cf1e6d80 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -8,10 +8,10 @@ import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .data import WyomingService @@ -69,6 +69,19 @@ async def async_step_hassio( await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() + uri = urlparse(discovery_info.config["uri"]) + for entry in self._async_current_entries(include_ignore=True): + if ( + entry.data[CONF_HOST] == uri.hostname + and entry.data[CONF_PORT] == uri.port + ): + return self.async_update_reload_and_abort( + entry, + unique_id=discovery_info.uuid, + reload_even_if_entry_is_unchanged=False, + reason="already_configured", + ) + self._hassio_discovery = discovery_info self.context.update( { @@ -104,7 +117,7 @@ async def async_step_hassio_confirm( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Zeroconf discovery info: %s", discovery_info) @@ -126,6 +139,19 @@ async def async_step_zeroconf( self.context["title_placeholders"] = {"name": self._name} + for entry in self._async_current_entries(include_ignore=True): + if ( + entry.data[CONF_HOST] == service.host + and entry.data[CONF_PORT] == service.port + and entry.source != SOURCE_HASSIO + ): + return self.async_update_reload_and_abort( + entry, + unique_id=unique_id, + reload_even_if_entry_is_unchanged=False, + reason="already_configured", + ) + self._service = service return await self.async_step_zeroconf_confirm() diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 23343cb0f8dfdc..d98f1f51d5438b 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -81,9 +81,16 @@ def name(self): @property def brightness(self): - """Return the brightness of the light.""" + """Return the brightness of the light, scaled to base class 0..255. + + This needs to be scaled from 0..x for use with X10 dimmers. + """ return self._brightness + def normalize_x10_brightness(self, brightness: float) -> float: + """Return calculated brightness values.""" + return int((brightness / 255) * 32) + @property def is_on(self): """Return true if light is on.""" @@ -91,11 +98,37 @@ def is_on(self): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - if self._is_cm11a: - x10_command(f"on {self._id}") - else: - x10_command(f"fon {self._id}") + old_brightness = self._brightness + if old_brightness == 0: + # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate + old_brightness = 255 self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness_diff = self.normalize_x10_brightness( + self._brightness + ) - self.normalize_x10_brightness(old_brightness) + command_suffix = "" + # heyu has quite a messy command structure - we'll just deal with it here + if brightness_diff == 0: + if self._is_cm11a: + command_prefix = "on" + else: + command_prefix = "fon" + elif brightness_diff > 0: + if self._is_cm11a: + command_prefix = "bright" + else: + command_prefix = "fbright" + command_suffix = f" {brightness_diff}" + else: + if self._is_cm11a: + if self._state: + command_prefix = "dim" + else: + command_prefix = "dimb" + else: + command_prefix = "fdim" + command_suffix = f" {-brightness_diff}" + x10_command(f"{command_prefix} {self._id}{command_suffix}") self._state = True def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +137,7 @@ def turn_off(self, **kwargs: Any) -> None: x10_command(f"off {self._id}") else: x10_command(f"foff {self._id}") + self._brightness = 0 self._state = False def update(self) -> None: diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 6252e6849d029f..e0484b80b7e1a3 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -7,11 +7,11 @@ import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_INTERFACE, @@ -153,7 +153,7 @@ async def async_step_select( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index a77b78c5a0961f..75b4ab1ecda1c7 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -7,14 +7,14 @@ "data": { "interface": "The network interface to use", "host": "IP address (optional)", - "mac": "Mac Address (optional)" + "mac": "MAC address (optional)" } }, "settings": { "title": "Optional settings", "description": "The key (password) can be retrieved using this tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. If the key is not provided only sensors will be accessible", "data": { - "key": "The key of your gateway", + "key": "The key of your Gateway", "name": "Name of the Gateway" } }, @@ -28,9 +28,9 @@ "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", - "invalid_key": "Invalid gateway key", + "invalid_key": "Invalid Gateway key", "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", - "invalid_mac": "Invalid Mac Address" + "invalid_mac": "Invalid MAC address" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b068f4a1e61c8b..c3ebc48d743644 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,6 @@ from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_CLOUD_COUNTRY, @@ -145,7 +145,7 @@ async def async_step_user( return await self.async_step_cloud() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index eb0d6bca205e42..6729ce2e0f42aa 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -260,10 +260,10 @@ def __init__(self, device, entry, unique_id, coordinator, description, enum_clas if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): # noqa: SLF001 + for key, val in enum_class._member_map_.items(): self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ # noqa: SLF001 + self._options_map = enum_class._member_map_ self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 17b6035321aea7..fa9584505e2f18 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -86,7 +86,7 @@ def __init__( ) -> None: """Initiate Yale door battery Sensor.""" super().__init__(coordinator, data) - self._attr_unique_id = f"{data["address"]}-battery" + self._attr_unique_id = f"{data['address']}-battery" @property def is_on(self) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 7ece2a3448bbb2..db63567fa926ac 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -84,7 +84,7 @@ async def _async_update_data(self) -> dict[str, Any]: contact["address"]: contact["_state"] for contact in door_windows } _sensor_battery_map = { - f"{contact["address"]}-battery": contact["_battery"] + f"{contact['address']}-battery": contact["_battery"] for contact in door_windows } _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index bd3ba0f01863e7..ebcf0b3af63ba6 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -92,7 +92,7 @@ "message": "Could not set lock, check system ready for lock" }, "could_not_trigger_panic": { - "message": "Could not trigger panic button for entity id {entity_id}: {error}" + "message": "Could not trigger panic button for entity ID {entity_id}: {error}" } } } diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index d6ad54c4a3d80a..c43e547a71e71c 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -10,10 +10,14 @@ from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import get_upnp_desc from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN @@ -81,7 +85,7 @@ def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( @@ -89,7 +93,7 @@ async def async_step_ssdp( ): return self.async_abort(reason="yxc_control_url_missing") - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment @@ -105,9 +109,7 @@ async def async_step_ssdp( self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 7a3a0a2f100000..35892764bcba4b 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -11,7 +11,7 @@ from yeelight.aio import AsyncBulb from yeelight.main import get_known_models -from homeassistant.components import dhcp, onboarding, ssdp, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -23,6 +23,9 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -69,21 +72,21 @@ def __init__(self) -> None: self._discovered_devices: dict[str, Any] = {} async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from homekit.""" self._discovered_ip = discovery_info.host return await self._async_handle_discovery() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery from dhcp.""" self._discovered_ip = discovery_info.ip return await self._async_handle_discovery() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from zeroconf.""" self._discovered_ip = discovery_info.host @@ -91,7 +94,7 @@ async def async_step_zeroconf( return await self._async_handle_discovery_with_unique_id() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 7e908396ff38d8..75156ab019bbeb 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -16,10 +16,11 @@ from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries -from homeassistant.components import network, ssdp +from homeassistant.components import network from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.util.async_ import create_eager_task from .const import ( @@ -171,7 +172,7 @@ def _async_start_flow(*_) -> None: self._hass, DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="", ssdp_st=SSDP_ST, ssdp_headers=response, diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index a011d493dc9cba..8d622de70e77d3 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -19,6 +19,11 @@ SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" +_SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( + (ATTR_VOLUME, lambda x: x), + (ATTR_TONE, lambda x: x.capitalize()), +) + def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" @@ -46,16 +51,16 @@ async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: identifier[1] ) ) is not None: - tone_param = service_data[ATTR_TONE].capitalize() - play_request = ClientRequest( - "playAudio", - { - ATTR_TONE: tone_param, - ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], - ATTR_VOLUME: service_data[ATTR_VOLUME], - ATTR_REPEAT: service_data[ATTR_REPEAT], - }, - ) + params = { + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_REPEAT: service_data[ATTR_REPEAT], + } + + for attr, transform in _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS: + if attr in service_data: + params[attr] = transform(service_data[attr]) + + play_request = ClientRequest("playAudio", params) await device_coordinator.device.call_device(play_request) hass.services.async_register( @@ -64,9 +69,9 @@ async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: schema=vol.Schema( { vol.Required(ATTR_TARGET_DEVICE): cv.string, - vol.Required(ATTR_TONE): cv.string, + vol.Optional(ATTR_TONE): cv.string, vol.Required(ATTR_TEXT_MESSAGE): cv.string, - vol.Required(ATTR_VOLUME): vol.All( + vol.Optional(ATTR_VOLUME): vol.All( vol.Coerce(int), vol.Range(min=0, max=15) ), vol.Optional(ATTR_REPEAT, default=0): vol.All( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 5f7a3ec3122135..7375962070e9b2 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -14,7 +14,6 @@ play_on_speaker_hub: selector: text: tone: - required: true default: "tip" selector: select: @@ -25,7 +24,6 @@ play_on_speaker_hub: - "tip" translation_key: speaker_tone volume: - required: true default: 8 selector: number: @@ -33,7 +31,6 @@ play_on_speaker_hub: max: 15 step: 1 repeat: - required: true default: 0 selector: number: diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 2f9a945450268f..cbb092405d7fed 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Speaker volume during playback." + "description": "Override the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ccc8cda0ff184..9a51e0fe0d174f 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==2.1.2"] + "requirements": ["youless-api==2.2.0"] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 449c2ccef91cfc..b748006336c5a5 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -4,9 +4,8 @@ import contextlib from contextlib import suppress -from dataclasses import dataclass from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial from ipaddress import IPv4Address, IPv6Address import logging import re @@ -30,12 +29,20 @@ __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, @@ -83,7 +90,11 @@ ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] -ATTR_PROPERTIES_ID: Final = "id" +_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( + _ATTR_PROPERTIES_ID, + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + "2026.2", +) CONFIG_SCHEMA = vol.Schema( { @@ -101,60 +112,36 @@ extra=vol.ALLOW_EXTRA, ) - -@dataclass(slots=True) -class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries. - - The ip_address is the most recently updated address - that is not a link local or unspecified address. - - The ip_addresses are all addresses in order of most - recently updated to least recently updated. - - The host is the string representation of the ip_address. - - The addresses are the string representations of the - ip_addresses. - - It is recommended to use the ip_address to determine - the address to connect to as it will be the most - recently updated address that is not a link local - or unspecified address. - """ - - ip_address: IPv4Address | IPv6Address - ip_addresses: list[IPv4Address | IPv6Address] - port: int | None - hostname: str - type: str - name: str - properties: dict[str, Any] - - @property - def host(self) -> str: - """Return the host.""" - return str(self.ip_address) - - @property - def addresses(self) -> list[str]: - """Return the addresses.""" - return [str(ip_address) for ip_address in self.ip_addresses] +_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant( + _ZeroconfServiceInfo, + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + "2026.2", +) @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: - """Zeroconf instance to be shared with other integrations that use it.""" - return cast(HaZeroconf, (await _async_get_instance(hass)).zeroconf) + """Get or create the shared HaZeroconf instance.""" + return cast(HaZeroconf, (_async_get_instance(hass)).zeroconf) @bind_hass async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf: - """Zeroconf instance to be shared with other integrations that use it.""" - return await _async_get_instance(hass) + """Get or create the shared HaAsyncZeroconf instance.""" + return _async_get_instance(hass) + +@callback +def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: + """Get or create the shared HaAsyncZeroconf instance. + + This method must be run in the event loop, and is an alternative + to the async_get_async_instance method when a coroutine cannot be used. + """ + return _async_get_instance(hass) -async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: + +def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) @@ -221,7 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ] - aio_zc = await _async_get_instance(hass, **zc_args) + aio_zc = _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) @@ -409,7 +396,7 @@ def _handle_config_entry_removed( def _async_dismiss_discoveries(self, name: str) -> None: """Dismiss all discoveries for the given name.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - ZeroconfServiceInfo, + _ZeroconfServiceInfo, lambda service_info: bool(service_info.name == name), ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -585,7 +572,7 @@ def async_get_homekit_discovery( return None -def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings @@ -605,10 +592,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: return None if TYPE_CHECKING: - assert ( - service.server is not None - ), "server cannot be none if there are addresses" - return ZeroconfServiceInfo( + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) + return _ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, port=service.port, @@ -674,3 +661,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# 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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 9ad92bb4bc7460..b301c1ad19118b 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.136.2"] + "requirements": ["zeroconf==0.140.1"] } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5cb67489423115..d41ae7dbfeeed0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -14,7 +14,7 @@ import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant.components import onboarding, usb, zeroconf +from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon @@ -35,6 +35,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN @@ -586,9 +588,7 @@ async def async_step_confirm( description_placeholders={CONF_NAME: self._title}, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" vid = discovery_info.vid pid = discovery_info.pid @@ -623,7 +623,7 @@ async def async_step_usb( return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" @@ -649,7 +649,7 @@ async def async_step_zeroconf( fallback_title = name.split("._", 1)[0] title = discovery_info.properties.get("name", fallback_title) - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=discovery_info.ip_address, ip_addresses=discovery_info.ip_addresses, port=port, diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index fc374f6c44d2b1..7bdfc54c986529 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -61,7 +61,7 @@ def battery_level(self) -> int | None: """ return self.entity_data.entity.battery_level - @property # type: ignore[explicit-override, misc] + @property # type: ignore[misc] def device_info(self) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 3e3d0642ca2298..77ba048312acc7 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -87,7 +87,7 @@ def device_info(self) -> DeviceInfo: manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) @callback diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 2440e18cf5389f..c31627d3dc3b20 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1170,7 +1170,7 @@ def async_add_entities( # broad exception to prevent a single entity from preventing an entire platform from loading # this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the # alternative is adding try/catch to each entity class __init__ method with a specific exception - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Error while adding entity from entity data: %s", entity_data ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 28d5f601671fff..f9323fe99df741 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.44"], + "requirements": ["zha==0.0.45"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 82c30b7678a579..aaf156290a7abd 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -29,6 +29,7 @@ from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs from .const import ( @@ -86,7 +87,7 @@ vol.Required("old_discovery_info"): vol.Schema( { vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA, - vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo, + vol.Exclusive("usb", "discovery"): UsbServiceInfo, } ), } diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index cb5c160e7b35cf..2f540da5ea71ea 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -124,7 +124,7 @@ def installed_version(self) -> str | None: return self.entity_data.entity.installed_version @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Should return a boolean (True if in progress, False if not). @@ -163,11 +163,7 @@ async def async_release_notes(self) -> str | None: """ if self.entity_data.device_proxy.device.is_mains_powered: - header = ( - "" - f"{OTA_MESSAGE_RELIABILITY}" - "" - ) + header = f"{OTA_MESSAGE_RELIABILITY}" else: header = ( "" diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index aa4aefe6d95c2b..af4999e5438dbf 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -85,11 +85,8 @@ def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: from_s = zone_event.data["old_state"] to_s = zone_event.data["new_state"] - if ( - from_s - and not location.has_location(from_s) - or to_s - and not location.has_location(to_s) + if (from_s and not location.has_location(from_s)) or ( + to_s and not location.has_location(to_s) ): return @@ -107,13 +104,8 @@ def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False - if ( - event == EVENT_ENTER - and not from_match - and to_match - or event == EVENT_LEAVE - and from_match - and not to_match + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match ): description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" hass.async_run_hass_job( diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 711eb14070de24..44adf6a12abe93 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -19,7 +19,6 @@ AddonManager, AddonState, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( SOURCE_USB, ConfigEntriesFlowManager, @@ -39,6 +38,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from . import disconnect_client @@ -405,9 +406,7 @@ async def async_step_zeroconf_confirm( }, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index e6cfc6c8b29e2d..639d2fbcd7a1a3 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -458,7 +458,7 @@ def _calculate_color_support(self) -> None: if warm_white and cool_white: self._supports_color_temp = True # only one white channel (warm white or cool white) = rgbw support - elif red and green and blue and warm_white or cool_white: + elif (red and green and blue and warm_white) or cool_white: self._supports_rgbw = True @callback diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index d1cb66ceafcde6..fe293fd178ba2f 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -488,10 +488,7 @@ async def async_set_config_parameter(self, service: ServiceCall) -> None: ) if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): _LOGGER.warning( - ( - "The following nodes do not have endpoint %x and will be " - "skipped: %s" - ), + "The following nodes do not have endpoint %x and will be skipped: %s", endpoint, nodes_without_endpoints, ) diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 1444bfc1b95684..d37d76a093bf0c 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -7,9 +7,9 @@ from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import helpers from .const import DOMAIN diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ade4cd855cac21..5a0f99df5ee4f0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -22,11 +22,10 @@ import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast from async_interrupt import interrupt from propcache import cached_property -from typing_extensions import TypeVar import voluptuous as vol from . import data_entry_flow, loader @@ -88,12 +87,12 @@ if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.dhcp import DhcpServiceInfo - from .components.ssdp import SsdpServiceInfo - from .components.usb import UsbServiceInfo - from .components.zeroconf import ZeroconfServiceInfo + from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + from .helpers.service_info.ssdp import SsdpServiceInfo + from .helpers.service_info.usb import UsbServiceInfo + from .helpers.service_info.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) @@ -137,8 +136,6 @@ ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 -_DataT = TypeVar("_DataT", default=Any) - class ConfigEntryState(Enum): """Config entry state.""" @@ -313,7 +310,7 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) -class ConfigEntry(Generic[_DataT]): +class ConfigEntry[_DataT = Any]: """Hold a configuration entry.""" entry_id: str @@ -691,10 +688,7 @@ async def __async_setup_with_context( self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), + "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, @@ -1890,7 +1884,7 @@ def async_entries( def async_loaded_entries(self, domain: str) -> list[ConfigEntry]: """Return loaded entries for a specific domain. - This will exclude ignored or disabled config entruis. + This will exclude ignored or disabled config entries. """ entries = self._entries.get_entries_for_domain(domain) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4c4d2fa90c26a5..699aebcafdf388 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,10 +28,10 @@ PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -647,6 +647,8 @@ class UnitOfElectricPotential(StrEnum): MICROVOLT = "µV" MILLIVOLT = "mV" VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" # Degree units diff --git a/homeassistant/core.py b/homeassistant/core.py index da7a206b14e7f3..74bcd844823d91 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -36,12 +36,12 @@ NotRequired, Self, TypedDict, + TypeVar, cast, overload, ) from propcache import cached_property, under_cached_property -from typing_extensions import TypeVar import voluptuous as vol from . import util @@ -332,7 +332,7 @@ class HassJob[**_P, _R_co]: we run the job. """ - __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache") + __slots__ = ("_cache", "_cancel_on_shutdown", "name", "target") def __init__( self, @@ -1153,8 +1153,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: await self.async_block_till_done() except TimeoutError: _LOGGER.warning( - "Timed out waiting for integrations to stop, the shutdown will" - " continue" + "Timed out waiting for integrations to stop, the shutdown will continue" ) self._async_log_running_tasks("stop integrations") @@ -1247,7 +1246,7 @@ def _async_log_running_tasks(self, stage: str) -> None: class Context: """The context that triggered something.""" - __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache") + __slots__ = ("_cache", "id", "origin_event", "parent_id", "user_id") def __init__( self, @@ -1322,12 +1321,12 @@ class Event(Generic[_DataT]): """Representation of an event within the bus.""" __slots__ = ( - "event_type", + "_cache", + "context", "data", + "event_type", "origin", "time_fired_timestamp", - "context", - "_cache", ) def __init__( @@ -1768,18 +1767,18 @@ class State: """ __slots__ = ( - "entity_id", - "state", + "_cache", "attributes", + "context", + "domain", + "entity_id", "last_changed", "last_reported", "last_updated", - "context", - "state_info", - "domain", - "object_id", "last_updated_timestamp", - "_cache", + "object_id", + "state", + "state_info", ) def __init__( @@ -2067,7 +2066,7 @@ def domain_states(self, key: str) -> ValuesView[State] | tuple[()]: class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") + __slots__ = ("_bus", "_loop", "_reservations", "_states", "_states_data") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" @@ -2405,7 +2404,7 @@ class SupportsResponse(enum.StrEnum): class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema", "domain", "service", "supports_response"] + __slots__ = ["domain", "job", "schema", "service", "supports_response"] def __init__( self, @@ -2432,7 +2431,7 @@ def __init__( class ServiceCall: """Representation of a call to a service.""" - __slots__ = ("hass", "domain", "service", "data", "context", "return_response") + __slots__ = ("context", "data", "domain", "hass", "return_response", "service") def __init__( self, @@ -2465,7 +2464,7 @@ def __repr__(self) -> str: class ServiceRegistry: """Offer the services over the eventbus.""" - __slots__ = ("_services", "_hass") + __slots__ = ("_hass", "_services") def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6df77443e7e1e4..e5ee5a7992287a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -12,9 +12,8 @@ from enum import StrEnum import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, cast +from typing import Any, Generic, Required, TypedDict, TypeVar, cast -from typing_extensions import TypeVar import voluptuous as vol from .core import HomeAssistant, callback diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 85fe55277fa9e2..0b2d2c071c5cfb 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -15,9 +15,9 @@ _function_cache: dict[str, Callable[[str, str, dict[str, str] | None], str]] = {} -def import_async_get_exception_message() -> ( - Callable[[str, str, dict[str, str] | None], str] -): +def import_async_get_exception_message() -> Callable[ + [str, str, dict[str, str] | None], str +]: """Return a method that can fetch a translated exception message. Defaults to English, requires translations to already be cached. @@ -174,7 +174,7 @@ def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" if self.total > 1: yield self._indent( - indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + indent, f"In '{self.type}' (item {self.index + 1} of {self.total}):" ) else: yield self._indent(indent, f"In '{self.type}':") diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index a105efc2685203..b4e6660275c1b9 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -192,6 +192,11 @@ "domain": "govee_ble", "local_name": "GVH5127*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5130*", + }, { "connectable": False, "domain": "govee_ble", @@ -433,6 +438,10 @@ "domain": "led_ble", "local_name": "MELK-*", }, + { + "domain": "led_ble", + "local_name": "LD-0003", + }, { "domain": "medcom_ble", "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14061d2e960240..b393e5c885195b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -331,6 +331,7 @@ "leaone", "led_ble", "lektrico", + "letpot", "lg_netcast", "lg_soundbar", "lg_thinq", @@ -415,6 +416,7 @@ "niko_home_control", "nina", "nmap_tracker", + "nmbs", "nobo_hub", "nordpool", "notion", @@ -487,6 +489,7 @@ "pvpc_hourly_pricing", "pyload", "qbittorrent", + "qbus", "qingping", "qnap", "qnap_qsw", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 67531ceced8e91..5fef087a868953 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1111,6 +1111,11 @@ "domain": "unifiprotect", "macaddress": "74ACB9*", }, + { + "domain": "velux", + "hostname": "velux_klf*", + "macaddress": "646184*", + }, { "domain": "verisure", "macaddress": "0023C1*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 96ca8a9f7669c3..2ee871964c9378 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1144,6 +1144,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "decorquip": { + "name": "Decorquip Dream", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "delijn": { "name": "De Lijn", "integration_type": "hub", @@ -3298,6 +3303,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "letpot": { + "name": "LetPot", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "leviton": { "name": "Leviton", "iot_standards": [ @@ -4213,7 +4224,7 @@ "nmbs": { "name": "NMBS", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "no_ip": { @@ -4970,6 +4981,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "qbus": { + "name": "Qbus", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "qingping": { "name": "Qingping", "integration_type": "hub", @@ -6183,7 +6200,8 @@ "name": "System Monitor", "integration_type": "hub", "config_flow": true, - "iot_class": "local_push" + "iot_class": "local_push", + "single_config_entry": true }, "tado": { "name": "Tado", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index f73388b203c013..72f160ee2ecdcc 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,11 @@ "fully_kiosk": [ "fully/deviceInfo/+", ], + "qbus": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state", + ], "tasmota": [ "tasmota/discovery/#", ], diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 0766e1ce01106f..203f01e7d68d40 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -729,6 +729,11 @@ "domain": "octoprint", }, ], + "_owserver._tcp.local.": [ + { + "domain": "onewire", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index f01ae325875ddf..b5f5ee9a961f71 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -9,15 +9,16 @@ from ssl import SSLContext import sys from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT -from aiohttp.resolver import AsyncResolver from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout +from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass @@ -82,6 +83,31 @@ async def json( return await super().json(*args, loads=loads, **kwargs) +class ChunkAsyncStreamIterator: + """Async iterator for chunked streams. + + Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields + bytes instead of tuple[bytes, bool]. + """ + + __slots__ = ("_stream",) + + def __init__(self, stream: aiohttp.StreamReader) -> None: + """Initialize.""" + self._stream = stream + + def __aiter__(self) -> Self: + """Iterate.""" + return self + + async def __anext__(self) -> bytes: + """Yield next chunk.""" + rv = await self._stream.readchunk() + if rv == (b"", False): + raise StopAsyncIteration + return rv[0] + + @callback @bind_hass def async_get_clientsession( @@ -337,7 +363,7 @@ def _async_get_connector( ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, - resolver=AsyncResolver(), + resolver=_async_make_resolver(hass), ) connectors[connector_key] = connector @@ -348,3 +374,8 @@ async def _async_close_connector(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_connector) return connector + + +@callback +def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: + return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index f74296a9fb127d..9c75af7262dfdd 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -9,6 +9,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -38,7 +39,7 @@ ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 7 +STORAGE_VERSION_MINOR = 8 class _AreaStoreData(TypedDict): @@ -46,11 +47,13 @@ class _AreaStoreData(TypedDict): aliases: list[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: list[str] name: str picture: str | None + temperature_entity_id: str | None created_at: str modified_at: str @@ -74,10 +77,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): aliases: set[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: set[str] = field(default_factory=set) picture: str | None + temperature_entity_id: str | None _cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False) @under_cached_property @@ -89,10 +94,12 @@ def json_fragment(self) -> json_fragment: "aliases": list(self.aliases), "area_id": self.id, "floor_id": self.floor_id, + "humidity_entity_id": self.humidity_entity_id, "icon": self.icon, "labels": list(self.labels), "name": self.name, "picture": self.picture, + "temperature_entity_id": self.temperature_entity_id, "created_at": self.created_at.timestamp(), "modified_at": self.modified_at.timestamp(), } @@ -138,11 +145,17 @@ async def _async_migrate_func( area["labels"] = [] if old_minor_version < 7: - # Version 1.7 adds created_at and modiefied_at + # Version 1.7 adds created_at and modified_at created_at = utc_from_timestamp(0).isoformat() for area in old_data["areas"]: area["created_at"] = area["modified_at"] = created_at + if old_minor_version < 8: + # Version 1.8 adds humidity_entity_id and temperature_entity_id + for area in old_data["areas"]: + area["humidity_entity_id"] = None + area["temperature_entity_id"] = None + if old_major_version > 1: raise NotImplementedError return old_data # type: ignore[return-value] @@ -242,11 +255,14 @@ def async_create( *, aliases: set[str] | None = None, floor_id: str | None = None, + humidity_entity_id: str | None = None, icon: str | None = None, labels: set[str] | None = None, picture: str | None = None, + temperature_entity_id: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("area_registry.async_create") if area := self.async_get_area_by_name(name): @@ -254,14 +270,22 @@ def async_create( f"The name {name} ({area.normalized_name}) is already in use" ) + if humidity_entity_id is not None: + _validate_humidity_entity(self.hass, humidity_entity_id) + + if temperature_entity_id is not None: + _validate_temperature_entity(self.hass, temperature_entity_id) + area = AreaEntry( aliases=aliases or set(), floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, id=self._generate_id(name), labels=labels or set(), name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) area_id = area.id self.areas[area_id] = area @@ -298,20 +322,24 @@ def async_update( *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( area_id, aliases=aliases, floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, labels=labels, name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) # Since updated may be the old or the new and we always fire # an event even if nothing has changed we cannot use async_fire_internal @@ -330,10 +358,12 @@ def _async_update( *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] @@ -342,14 +372,22 @@ def _async_update( attr_name: value for attr_name, value in ( ("aliases", aliases), + ("floor_id", floor_id), + ("humidity_entity_id", humidity_entity_id), ("icon", icon), ("labels", labels), ("picture", picture), - ("floor_id", floor_id), + ("temperature_entity_id", temperature_entity_id), ) if value is not UNDEFINED and value != getattr(old, attr_name) } + if "humidity_entity_id" in new_values and humidity_entity_id is not None: + _validate_humidity_entity(self.hass, new_values["humidity_entity_id"]) + + if "temperature_entity_id" in new_values and temperature_entity_id is not None: + _validate_temperature_entity(self.hass, new_values["temperature_entity_id"]) + if name is not UNDEFINED and name != old.name: new_values["name"] = name @@ -378,11 +416,13 @@ async def async_load(self) -> None: areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), floor_id=area["floor_id"], + humidity_entity_id=area["humidity_entity_id"], icon=area["icon"], id=area["id"], labels=set(area["labels"]), name=area["name"], picture=area["picture"], + temperature_entity_id=area["temperature_entity_id"], created_at=datetime.fromisoformat(area["created_at"]), modified_at=datetime.fromisoformat(area["modified_at"]), ) @@ -398,11 +438,13 @@ def _data_to_save(self) -> AreasRegistryStoreData: { "aliases": list(entry.aliases), "floor_id": entry.floor_id, + "humidity_entity_id": entry.humidity_entity_id, "icon": entry.icon, "id": entry.id, "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, + "temperature_entity_id": entry.temperature_entity_id, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), } @@ -477,3 +519,33 @@ def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaE def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]: """Return entries that match a label.""" return registry.areas.get_areas_for_label(label_id) + + +def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate temperature entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + ): + raise ValueError(f"Entity {entity_id} is not a temperature sensor") + + +def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate humidity entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + ): + raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4b5e2f277a020a..a8e8fa4160dd0f 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -220,7 +220,7 @@ async def _get_integration( except (vol.Invalid, HomeAssistantError) as ex: _comp_error(ex, domain, config, config[domain]) continue - except Exception as err: # noqa: BLE001 + except Exception as err: logging.getLogger(__name__).exception( "Unexpected error validating config" ) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 86d3450c3a012f..08b58aedde4fd1 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -11,9 +11,8 @@ from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict +from typing import Any, TypedDict -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -37,8 +36,6 @@ CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" -_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) - @dataclass(slots=True) class CollectionChange: @@ -448,7 +445,7 @@ async def async_load(self, data: list[dict]) -> None: @dataclass(slots=True, frozen=True) -class _CollectionLifeCycle(Generic[_EntityT]): +class _CollectionLifeCycle[_EntityT: Entity = Entity]: """Life cycle for a collection of entities.""" domain: str @@ -523,7 +520,7 @@ async def _collection_changed(self, change_set: Iterable[CollectionChange]) -> N @callback -def sync_entity_lifecycle( +def sync_entity_lifecycle[_EntityT: Entity = Entity]( hass: HomeAssistant, domain: str, platform: str, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5952e28a1eb9b5..695af80bc1c563 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -884,10 +884,8 @@ def time( condition_trace_update_result(weekday=weekday, now_weekday=now_weekday) if ( - isinstance(weekday, str) - and weekday != now_weekday - or now_weekday not in weekday - ): + isinstance(weekday, str) and weekday != now_weekday + ) or now_weekday not in weekday: return False return True diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef813f0..45e2e7cf35ff26 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -16,11 +16,11 @@ import asyncio from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.dhcp import DhcpServiceInfo - from homeassistant.components.ssdp import SsdpServiceInfo - from homeassistant.components.zeroconf import ZeroconfServiceInfo + from .service_info.dhcp import DhcpServiceInfo from .service_info.mqtt import MqttServiceInfo + from .service_info.ssdp import SsdpServiceInfo + from .service_info.zeroconf import ZeroconfServiceInfo type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] @@ -67,9 +67,11 @@ async def async_step_confirm( in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3681e941eee8d4..2c8dbe69c221ea 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,8 +1,6 @@ """Helpers for config validation using voluptuous.""" -# PEP 563 seems to break typing.get_type_hints when used -# with PEP 695 syntax. Fixed in Python 3.13. -# from __future__ import annotations +from __future__ import annotations from collections.abc import Callable, Hashable, Mapping import contextlib @@ -354,7 +352,7 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast("list[_T]", value) if isinstance(value, list) else [value] + return cast(list[_T], value) if isinstance(value, list) else [value] def entity_id(value: Any) -> str: @@ -676,11 +674,7 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if ( - type(value) is str # noqa: E721 - or type(value) is NodeStrClass - or isinstance(value, str) - ): + if type(value) is str or type(value) is NodeStrClass or isinstance(value, str): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index adb2062a8ea58e..b15d8b9e6073b2 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations from http import HTTPStatus -from typing import Any, Generic +from typing import Any, Generic, TypeVar from aiohttp import web -from typing_extensions import TypeVar import voluptuous as vol import voluptuous_serialize diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 81f7821ec79253..f02c6507d023d2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -4,7 +4,7 @@ from collections.abc import Callable from contextlib import suppress -from enum import Enum, EnumType, _EnumDict +from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict import functools import inspect import logging @@ -255,7 +255,7 @@ class DeprecatedConstant(NamedTuple): class DeprecatedConstantEnum(NamedTuple): """Deprecated constant.""" - enum: Enum + enum: StrEnum | IntEnum | IntFlag breaks_in_ha_version: str | None @@ -306,7 +306,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A replacement = deprecated_const.replacement breaks_in_ha_version = deprecated_const.breaks_in_ha_version elif isinstance(deprecated_const, DeprecatedConstantEnum): - value = deprecated_const.enum.value + value = deprecated_const.enum replacement = ( f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 981430f192d233..2890f607d595a5 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -958,16 +958,6 @@ def async_update_device( # noqa: C901 new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries - for attr_name, setvalue in ( - ("connections", merge_connections), - ("identifiers", merge_identifiers), - ): - old_value = getattr(old, attr_name) - # If not undefined, check if `value` contains new items. - if setvalue is not UNDEFINED and not setvalue.issubset(old_value): - new_values[attr_name] = old_value | setvalue - old_values[attr_name] = old_value - if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a5a790b7ce5178..350ae6dbd6af48 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -154,7 +154,7 @@ def _format_err[*_Ts]( return ( # Functions wrapped in partial do not have a __name__ - f"Exception in {getattr(target, "__name__", None) or target} " + f"Exception in {getattr(target, '__name__', None) or target} " f"when dispatching '{signal}': {args}" ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 91845cdf5214d3..9e8fe40c6b0c32 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -1028,7 +1028,7 @@ def _stringify_state(self, available: bool) -> str: return STATE_UNAVAILABLE if (state := self.state) is None: return STATE_UNKNOWN - if type(state) is str: # noqa: E721 + if type(state) is str: # fast path for strings return state if isinstance(state, float): @@ -1480,9 +1480,9 @@ async def async_internal_added_to_hass(self) -> None: if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests - assert ( - not self.registry_entry.disabled_by - ), f"Entity '{self.entity_id}' is being added while it's disabled" + assert not self.registry_entry.disabled_by, ( + f"Entity '{self.entity_id}' is being added while it's disabled" + ) self.async_on_remove( async_track_entity_registry_updated_event( @@ -1639,6 +1639,31 @@ def _suggest_report_issue(self) -> str: self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1be7289401ccfa..02508e9ee9e708 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,9 +7,7 @@ from datetime import timedelta import logging from types import ModuleType -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry @@ -39,8 +37,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" -_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity) - @bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: @@ -64,7 +60,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: await entity_obj.async_update_ha_state(True) -class EntityComponent(Generic[_EntityT]): +class EntityComponent[_EntityT: entity.Entity = entity.Entity]: """The EntityComponent manages platforms that manage entities. An example of an entity component is 'light', which manages platforms such diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9d50b7ae83b9e9..3e8c57562b275c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -648,6 +648,8 @@ def _validate_item( domain: str, platform: str, *, + config_entry_id: str | None | UndefinedType = None, + device_id: str | None | UndefinedType = None, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, hidden_by: RegistryEntryHider | None | UndefinedType = None, @@ -665,12 +667,21 @@ def _validate_item( # In HA Core 2025.10, we should fail if unique_id is not a string report_issue = async_suggest_report_issue(hass, integration_domain=platform) _LOGGER.error( - ("'%s' from integration %s has a non string unique_id" " '%s', please %s"), + "'%s' from integration %s has a non string unique_id '%s', please %s", domain, platform, unique_id, report_issue, ) + if config_entry_id and config_entry_id is not UNDEFINED: + if not hass.config_entries.async_get_entry(config_entry_id): + raise ValueError( + f"Can't link entity to unknown config entry {config_entry_id}" + ) + if device_id and device_id is not UNDEFINED: + device_registry = dr.async_get(hass) + if not device_registry.async_get(device_id): + raise ValueError(f"Device {device_id} does not exist") if ( disabled_by and disabled_by is not UNDEFINED @@ -794,7 +805,7 @@ def async_generate_entity_id( tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( - f"{preferred_string[:MAX_LENGTH_STATE_ENTITY_ID-len_suffix]}_{tries}" + f"{preferred_string[: MAX_LENGTH_STATE_ENTITY_ID - len_suffix]}_{tries}" ) return test_string @@ -859,6 +870,8 @@ def async_get_or_create( self.hass, domain, platform, + config_entry_id=config_entry_id, + device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, @@ -1090,6 +1103,8 @@ def _async_update_entity( self.hass, old.domain, old.platform, + config_entry_id=config_entry_id, + device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 72a4ef3c05065c..b363bc21e8616d 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -951,8 +951,7 @@ def _template_changed_listener( if ( not isinstance(last_result, TemplateError) and result_as_boolean(last_result) - or not result_as_boolean(result) - ): + ) or not result_as_boolean(result): return hass.async_run_hass_job( diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 22f8e2acbeb1a3..68daf5c793915b 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -46,9 +46,9 @@ def request_handler_factory( ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." + assert is_coroutinefunction or is_callback(handler), ( + "Handler should be a coroutine or a callback." + ) async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index ce8205eb915218..a8c1b0b2186c8b 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -78,7 +78,7 @@ async def _async_get_component_icons( class _IconsCache: """Cache for icons.""" - __slots__ = ("_hass", "_loaded", "_cache", "_lock") + __slots__ = ("_cache", "_hass", "_loaded", "_lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 468539f5a9d011..2874269892c944 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -58,6 +58,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" +INTENT_BROADCAST = "HassBroadcast" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -1201,17 +1202,17 @@ class Intent: """Hold the intent.""" __slots__ = [ + "assistant", + "category", + "context", + "conversation_agent_id", + "device_id", "hass", - "platform", "intent_type", + "language", + "platform", "slots", "text_input", - "context", - "language", - "category", - "assistant", - "device_id", - "conversation_agent_id", ] def __init__( diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index ebb74856429963..a97dd48bf614d9 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,7 +12,7 @@ import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # noqa: F401 +from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, SerializationError, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 38d80d5649d00c..f66794165f090f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -15,10 +15,6 @@ from voluptuous_openapi import UNSUPPORTED, convert from homeassistant.components.climate import INTENT_GET_TEMPERATURE -from homeassistant.components.conversation import ( - ConversationTraceEventType, - async_conversation_trace_append, -) from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -85,7 +81,7 @@ def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: @callback -def async_register_api(hass: HomeAssistant, api: API) -> None: +def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: """Register an API to be exposed to LLMs.""" apis = _async_get_apis(hass) @@ -94,6 +90,13 @@ def async_register_api(hass: HomeAssistant, api: API) -> None: apis[api.id] = api + @callback + def unregister() -> None: + """Unregister the API.""" + apis.pop(api.id) + + return unregister + async def async_get_api( hass: HomeAssistant, api_id: str, llm_context: LLMContext @@ -164,6 +167,12 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.conversation import ( + ConversationTraceEventType, + async_conversation_trace_append, + ) + async_conversation_trace_append( ConversationTraceEventType.TOOL_CALL, {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a67ef60c799533..1fd0e08988c6b8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -756,10 +756,8 @@ async def _async_call_service_step(self) -> None: ) running_script = ( - params[CONF_DOMAIN] == "automation" - and params[CONF_SERVICE] == "trigger" - or params[CONF_DOMAIN] in ("python_script", "script") - ) + params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" + ) or params[CONF_DOMAIN] in ("python_script", "script") trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( self._hass.async_create_task_internal( @@ -1589,6 +1587,9 @@ def _find_referenced_target( target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1637,9 @@ def _find_referenced_devices( for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1688,9 @@ def _find_referenced_entities( for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: @@ -1770,7 +1777,7 @@ async def async_run( f"{self.domain}.{self.name} which is already running " "in the current execution path; " "Traceback (most recent call last):\n" - f"{"\n".join(formatted_stack)}", + f"{'\n'.join(formatted_stack)}", level=logging.WARNING, ) return None @@ -1834,7 +1841,7 @@ async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType def _prep_repeat_script(self, step: int) -> Script: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Repeat at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Repeat at step {step + 1}") sub_script = Script( self._hass, action[CONF_REPEAT][CONF_SEQUENCE], @@ -1857,7 +1864,7 @@ def _get_repeat_script(self, step: int) -> Script: async def _async_prep_choose_data(self, step: int) -> _ChooseData: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Choose at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Choose at step {step + 1}") choices = [] for idx, choice in enumerate(action[CONF_CHOOSE], start=1): conditions = [ @@ -1911,7 +1918,7 @@ async def _async_get_choose_data(self, step: int) -> _ChooseData: async def _async_prep_if_data(self, step: int) -> _IfData: """Prepare data for an if statement.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"If at step {step+1}") + step_name = action.get(CONF_ALIAS, f"If at step {step + 1}") conditions = [ await self._async_get_condition(config) for config in action[CONF_IF] @@ -1962,7 +1969,7 @@ async def _async_get_if_data(self, step: int) -> _IfData: async def _async_prep_parallel_scripts(self, step: int) -> list[Script]: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Parallel action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Parallel action at step {step + 1}") parallel_scripts: list[Script] = [] for idx, parallel_script in enumerate(action[CONF_PARALLEL], start=1): parallel_name = parallel_script.get(CONF_ALIAS, f"parallel {idx}") @@ -1994,7 +2001,7 @@ async def _async_get_parallel_scripts(self, step: int) -> list[Script]: async def _async_prep_sequence_script(self, step: int) -> Script: """Prepare a sequence script.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step + 1}") sequence_script = Script( self._hass, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 35135010452b6c..255739c0059f8a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -133,8 +133,7 @@ def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: domain, enum, option = option_or_feature.split(".", 2) except ValueError as exc: raise vol.Invalid( - f"Invalid {label} '{option_or_feature}', expected " - ".." + f"Invalid {label} '{option_or_feature}', expected .." ) from exc base_components = _base_components() @@ -226,7 +225,7 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" - __slots__ = ("entity_ids", "device_ids", "area_ids", "floor_ids", "label_ids") + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" @@ -503,7 +502,7 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: @bind_hass -def async_extract_referenced_entity_ids( # noqa: C901 +def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py new file mode 100644 index 00000000000000..47479a53a8a6a2 --- /dev/null +++ b/homeassistant/helpers/service_info/dhcp.py @@ -0,0 +1,14 @@ +"""DHCP discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str diff --git a/homeassistant/helpers/service_info/ssdp.py b/homeassistant/helpers/service_info/ssdp.py new file mode 100644 index 00000000000000..4a3a5a24474ea2 --- /dev/null +++ b/homeassistant/helpers/service_info/ssdp.py @@ -0,0 +1,41 @@ +"""SSDP discovery data.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for accessing info from retrieved UPnP device description +ATTR_ST: Final = "st" +ATTR_NT: Final = "nt" +ATTR_UPNP_DEVICE_TYPE: Final = "deviceType" +ATTR_UPNP_FRIENDLY_NAME: Final = "friendlyName" +ATTR_UPNP_MANUFACTURER: Final = "manufacturer" +ATTR_UPNP_MANUFACTURER_URL: Final = "manufacturerURL" +ATTR_UPNP_MODEL_DESCRIPTION: Final = "modelDescription" +ATTR_UPNP_MODEL_NAME: Final = "modelName" +ATTR_UPNP_MODEL_NUMBER: Final = "modelNumber" +ATTR_UPNP_MODEL_URL: Final = "modelURL" +ATTR_UPNP_SERIAL: Final = "serialNumber" +ATTR_UPNP_SERVICE_LIST: Final = "serviceList" +ATTR_UPNP_UDN: Final = "UDN" +ATTR_UPNP_UPC: Final = "UPC" +ATTR_UPNP_PRESENTATION_URL: Final = "presentationURL" + + +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" + + ssdp_usn: str + ssdp_st: str + upnp: Mapping[str, Any] + ssdp_location: str | None = None + ssdp_nt: str | None = None + ssdp_udn: str | None = None + ssdp_ext: str | None = None + ssdp_server: str | None = None + ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) + x_homeassistant_matching_domains: set[str] = field(default_factory=set) diff --git a/homeassistant/helpers/service_info/usb.py b/homeassistant/helpers/service_info/usb.py new file mode 100644 index 00000000000000..c7d6f6ea1435f0 --- /dev/null +++ b/homeassistant/helpers/service_info/usb.py @@ -0,0 +1,17 @@ +"""USB discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class UsbServiceInfo(BaseServiceInfo): + """Prepared info from usb entries.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/helpers/service_info/zeroconf.py b/homeassistant/helpers/service_info/zeroconf.py new file mode 100644 index 00000000000000..a91bc5e77d9b46 --- /dev/null +++ b/homeassistant/helpers/service_info/zeroconf.py @@ -0,0 +1,50 @@ +"""Zeroconf discovery data.""" + +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] +ATTR_PROPERTIES_ID: Final = "id" + + +@dataclass(slots=True) +class ZeroconfServiceInfo(BaseServiceInfo): + """Prepared info from mDNS entries. + + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] + port: int | None + hostname: str + type: str + name: str + properties: dict[str, Any] + + @property + def host(self) -> str: + """Return the host.""" + return str(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [str(ip_address) for ip_address in self.ip_addresses] diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 20e4ee82162de9..075fc50b49af86 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -3,15 +3,22 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine import functools -from typing import Any, cast, overload +from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey type _FuncType[_T] = Callable[[HomeAssistant], _T] +type _Coro[_T] = Coroutine[Any, Any, _T] + + +@overload +def singleton[_T]( + data_key: HassKey[_T], *, async_: Literal[True] +) -> Callable[[_FuncType[_Coro[_T]]], _FuncType[_Coro[_T]]]: ... @overload @@ -24,29 +31,37 @@ def singleton[_T]( def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... -def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: +def singleton[_S, _T, _U]( + data_key: Any, *, async_: bool = False +) -> Callable[[_FuncType[_S]], _FuncType[_S]]: """Decorate a function that should be called once per instance. Result will be cached and simultaneous calls will be handled. """ - def wrapper(func: _FuncType[_T]) -> _FuncType[_T]: + @overload + def wrapper(func: _FuncType[_Coro[_T]]) -> _FuncType[_Coro[_T]]: ... + + @overload + def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ... + + def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" if not asyncio.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass @functools.wraps(func) - def wrapped(hass: HomeAssistant) -> _T: + def wrapped(hass: HomeAssistant) -> _U: if data_key not in hass.data: hass.data[data_key] = func(hass) - return cast(_T, hass.data[data_key]) + return cast(_U, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) - async def async_wrapped(hass: HomeAssistant) -> Any: + async def async_wrapped(hass: HomeAssistant) -> _T: if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() result = await func(hass) @@ -62,6 +77,45 @@ async def async_wrapped(hass: HomeAssistant) -> Any: return cast(_T, obj_or_evt) - return async_wrapped # type: ignore[return-value] + return async_wrapped return wrapper + + +async def _test_singleton_typing(hass: HomeAssistant) -> None: + """Test singleton overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + # Test HassKey + key = HassKey[int]("key") + + @singleton(key) + def func(hass: HomeAssistant) -> int: + return 2 + + @singleton(key, async_=True) + async def async_func(hass: HomeAssistant) -> int: + return 2 + + assert_type(func(hass), int) + assert_type(await async_func(hass), int) + + # Test invalid use of 'async_' with sync function + @singleton(key, async_=True) # type: ignore[arg-type] + def func_error(hass: HomeAssistant) -> int: + return 2 + + # Test string key + other_key = "key" + + @singleton(other_key) + def func2(hass: HomeAssistant) -> str: + return "" + + @singleton(other_key) + async def async_func2(hass: HomeAssistant) -> str: + return "" + + assert_type(func2(hass), str) + assert_type(await async_func2(hass), str) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5b4a48bb07c76e..21d49df2a67591 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -386,19 +386,19 @@ class RenderInfo: """Holds information about a template render.""" __slots__ = ( - "template", - "filter_lifecycle", - "filter", "_result", - "is_static", - "exception", "all_states", "all_states_lifecycle", "domains", "domains_lifecycle", "entities", - "rate_limit", + "exception", + "filter", + "filter_lifecycle", "has_time", + "is_static", + "rate_limit", + "template", ) def __init__(self, template: Template) -> None: @@ -507,17 +507,17 @@ class Template: __slots__ = ( "__weakref__", - "template", - "hass", - "is_static", - "_compiled_code", "_compiled", + "_compiled_code", "_exc_info", + "_hash_cache", "_limited", - "_strict", "_log_fn", - "_hash_cache", "_renders", + "_strict", + "hass", + "is_static", + "template", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -601,7 +601,7 @@ def render( or filter depending on hass or the state machine. """ if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) assert self.hass is not None, "hass variable not set on template" @@ -630,7 +630,7 @@ def async_render( self._renders += 1 if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) @@ -651,7 +651,7 @@ def async_render( render_result = render_result.strip() - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -826,7 +826,7 @@ def async_render_with_possible_json_value( ) return value if error_value is _SENTINEL else error_value - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -841,16 +841,16 @@ def _ensure_compiled( self.ensure_valid() assert self.hass is not None, "hass variable not set on template" - assert ( - self._limited is None or self._limited == limited - ), "can't change between limited and non limited template" - assert ( - self._strict is None or self._strict == strict - ), "can't change between strict and non strict template" + assert self._limited is None or self._limited == limited, ( + "can't change between limited and non limited template" + ) + assert self._strict is None or self._strict == strict, ( + "can't change between strict and non strict template" + ) assert not (strict and limited), "can't combine strict and limited template" - assert ( - self._log_fn is None or self._log_fn == log_fn - ), "can't change custom log function" + assert self._log_fn is None or self._log_fn == log_fn, ( + "can't change custom log function" + ) assert self._compiled_code is not None, "template code was not compiled" self._limited = limited @@ -991,7 +991,7 @@ def __repr__(self) -> str: class DomainStates: """Class to expose a specific HA domain as attributes.""" - __slots__ = ("_hass", "_domain") + __slots__ = ("_domain", "_hass") __setitem__ = _readonly __delitem__ = _readonly @@ -1035,7 +1035,7 @@ def __repr__(self) -> str: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "_state") + __slots__ = ("_collect", "_entity_id", "_hass", "_state") _state: State @@ -1873,14 +1873,19 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) return state_obj is not None and ( - state_obj.state == state or isinstance(state, list) and state_obj.state in state + state_obj.state == state + or (isinstance(state, list) and state_obj.state in state) ) def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool: """Test if a state's attribute is a specific value.""" - attr = state_attr(hass, entity_id, name) - return attr is not None and attr == value + if (state_obj := _get_state(hass, entity_id)) is not None: + attr = state_obj.attributes.get(name, _SENTINEL) + if attr is _SENTINEL: + return False + return bool(attr == value) + return False def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 431a7a7d1f8cee..d191d474480de3 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -23,11 +23,11 @@ class TraceElement: "_child_run_id", "_error", "_last_variables", - "path", "_result", - "reuse_by_child", "_timestamp", "_variables", + "path", + "reuse_by_child", ) def __init__(self, variables: TemplateVarsType, path: str) -> None: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 01c47aa8d0d578..fdfefc9bff4d0b 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -147,7 +147,7 @@ class _TranslationsCacheData: class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "cache_data", "lock") + __slots__ = ("cache_data", "hass", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6cc4584935e5f4..62dcb2622e792c 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -9,13 +9,12 @@ import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp from propcache import cached_property import requests -from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -37,11 +36,6 @@ REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _DataT = TypeVar("_DataT", default=dict[str, Any]) -_DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", - bound="DataUpdateCoordinator[Any]", - default="DataUpdateCoordinator[dict[str, Any]]", -) class UpdateFailed(HomeAssistantError): @@ -365,7 +359,7 @@ async def _async_refresh( # noqa: C901 self._async_unsub_refresh() self._debounced_refresh.async_cancel() - if self._shutdown_requested or scheduled and self.hass.is_stopping: + if self._shutdown_requested or (scheduled and self.hass.is_stopping): return if log_timing := self.logger.isEnabledFor(logging.DEBUG): @@ -565,7 +559,11 @@ async def async_update(self) -> None: """ -class CoordinatorEntity(BaseCoordinatorEntity[_DataUpdateCoordinatorT]): +class CoordinatorEntity[ + _DataUpdateCoordinatorT: DataUpdateCoordinator[Any] = DataUpdateCoordinator[ + dict[str, Any] + ] +](BaseCoordinatorEntity[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" def __init__( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 93dc7677bba97d..39dbe20c7c6cba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1765,8 +1765,7 @@ def async_suggest_report_issue( if not integration_domain: return "report it to the custom integration author" return ( - f"report it to the author of the '{integration_domain}' " - "custom integration" + f"report it to the author of the '{integration_domain}' custom integration" ) return f"create a bug report at {issue_tracker}" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aeeeee70e37aa1..133c5bb76edf77 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,6 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b5 +aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 @@ -17,25 +18,25 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 bleak==0.22.3 -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.24.3 -fnv-hash-fast==1.0.2 +dbus-fast==2.30.2 +fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.6.0 -hass-nabucasa==0.87.0 +habluetooth==3.9.2 +hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250109.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 @@ -59,20 +60,20 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 +securetar==2025.1.3 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.8 -voluptuous-openapi==0.0.5 +uv==0.5.18 +voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.136.2 +zeroconf==0.140.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -107,7 +108,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 httpcore==1.0.5 @@ -116,7 +117,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py index 509a35d33ae2a9..e008305727275b 100644 --- a/homeassistant/util/event_type.py +++ b/homeassistant/util/event_type.py @@ -6,14 +6,10 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, Generic +from typing import Any -from typing_extensions import TypeVar -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) - - -class EventType(str, Generic[_DataT]): +class EventType[_DataT: Mapping[str, Any] = Mapping[str, Any]](str): """Custom type for Event.event_type. At runtime this is a generic subclass of str. diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi index 4285e54e8c98b1..f9cb140440f6f2 100644 --- a/homeassistant/util/event_type.pyi +++ b/homeassistant/util/event_type.pyi @@ -2,15 +2,15 @@ # ruff: noqa: PYI021 # Allow docstrings from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar __all__ = [ "EventType", ] -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +_DataT = TypeVar( # needs to be invariant + "_DataT", bound=Mapping[str, Any], default=Mapping[str, Any] +) class EventType(Generic[_DataT]): """Custom type for Event.event_type. At runtime delegated to str. diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 968567ae0c9239..a935d44d5856b6 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -46,7 +46,7 @@ def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayT """Parse JSON data and ensure result is a list.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") @@ -55,7 +55,7 @@ def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjec """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") @@ -95,7 +95,7 @@ def load_json_array( default = [] value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value _LOGGER.exception( "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename @@ -115,7 +115,7 @@ def load_json_object( default = {} value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value _LOGGER.exception( "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index d75930130469d5..bebd399a5cddad 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -93,8 +93,9 @@ def raise_for_blocking_call( return if found_frame is None: - raise RuntimeError( # noqa: TRY200 - f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} " + raise RuntimeError( # noqa: B904 + f"Caught blocking call to {func.__name__} " + f"with args {mapped_args.get('args')} " f"in {offender_filename}, line {offender_lineno}: {offender_line} " "inside the event loop; " "This is causing stability issues. " diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 08a2c2a39672c4..70d7dc80505ff7 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -98,8 +98,7 @@ def is_host_valid(host: str) -> bool: return False if re.match(r"^[0-9\.]+$", host): # reject invalid IPv4 return False - if host.endswith("."): # dot at the end is correct - host = host[:-1] + host = host.removesuffix(".") allowed = re.compile(r"(?!-)[A-Z\d\-]{1,63}(? bool: """Confirm if function should be checked.""" return ( self.function_name == node.name - or self.has_async_counterpart - and node.name == f"async_{self.function_name}" - or self.function_name.endswith("*") - and node.name.startswith(self.function_name[:-1]) + or ( + self.has_async_counterpart + and node.name == f"async_{self.function_name}" + ) + or ( + self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) + ) ) @@ -1017,6 +1021,34 @@ class ClassTypeHintMatch: return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="async_handle_async_webrtc_offer", + arg_types={ + 1: "str", + 2: "str", + 3: "WebRTCSendMessage", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_on_webrtc_candidate", + arg_types={ + 1: "str", + 2: "RTCIceCandidateInit", + }, + return_type=None, + ), + TypeHintMatch( + function_name="close_webrtc_session", + arg_types={ + 1: "str", + }, + return_type=None, + ), + TypeHintMatch( + function_name="_async_get_webrtc_client_configuration", + return_type="WebRTCClientConfiguration", + ), ], ), ], @@ -2970,8 +3002,8 @@ def _is_valid_type( isinstance(node, nodes.Subscript) and isinstance(node.value, nodes.Name) and node.value.name in _KNOWN_GENERIC_TYPES - or isinstance(node, nodes.Name) - and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) + ) or ( + isinstance(node, nodes.Name) and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) ): return True diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 194f99ae700a76..2fe70fad10dda8 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -268,9 +268,8 @@ def _visit_importfrom_relative( self, current_package: str, node: nodes.ImportFrom ) -> None: """Check for improper 'from ._ import _' invocations.""" - if ( - node.level <= 1 - or not current_package.startswith("homeassistant.components.") + if node.level <= 1 or ( + not current_package.startswith("homeassistant.components.") and not current_package.startswith("tests.components.") ): return diff --git a/pyproject.toml b/pyproject.toml index 2570b5163c72c3..73795f4ccd58f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,10 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.12.0" +requires-python = ">=3.13.0" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to @@ -32,6 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", + "aiohttp-asyncmdnsresolver==0.0.1", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", @@ -43,10 +43,10 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.87.0", + "hass-nabucasa==0.88.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", @@ -66,22 +66,23 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2024.11.0", + "securetar==2025.1.3", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.0.2", + "ulid-transform==1.2.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.8", + "uv==0.5.18", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.5", + "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", + "zeroconf==0.140.1" ] [project.urls] @@ -102,7 +103,7 @@ include-package-data = true include = ["homeassistant*"] [tool.pylint.MAIN] -py-version = "3.12" +py-version = "3.13" # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 @@ -699,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.8.0" +required-version = ">=0.9.1" [tool.ruff.lint] select = [ @@ -753,12 +754,27 @@ select = [ "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access - # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear + "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition + "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes + "RUF200", # Failed to parse pyproject.toml: {message} "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file @@ -837,7 +853,6 @@ ignore = [ "Q", "COM812", "COM819", - "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605" diff --git a/requirements.txt b/requirements.txt index addea3da1d38b0..6b934c101e7c48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ aiohasupervisor==0.2.2b5 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 +aiohttp-asyncmdnsresolver==0.0.1 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -19,8 +20,8 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.0.2 -hass-nabucasa==0.87.0 +fnv-hash-fast==1.2.2 +hass-nabucasa==0.88.1 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 @@ -37,16 +38,17 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 +securetar==2025.1.3 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.8 +uv==0.5.18 voluptuous==0.15.2 voluptuous-serialize==2.6.0 -voluptuous-openapi==0.0.5 +voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 +zeroconf==0.140.1 diff --git a/requirements_all.txt b/requirements_all.txt index 23149b43350c26..4480a06f803ca8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.0 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -273,7 +273,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -285,7 +285,7 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 @@ -321,7 +321,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -347,7 +347,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -470,7 +470,7 @@ anthemav==1.4.1 anthropic==0.31.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -488,7 +488,7 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.4.0 +aranet4==2.5.0 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -594,7 +594,7 @@ bizkaibus==0.1.1 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -619,7 +619,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -628,7 +628,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 @@ -710,7 +710,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -732,7 +732,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.30.2 # homeassistant.components.debugpy debugpy==1.8.11 @@ -744,7 +744,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -755,7 +755,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -815,7 +815,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.5 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 @@ -830,7 +830,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -934,7 +934,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 @@ -946,7 +946,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 @@ -959,7 +959,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 @@ -993,7 +993,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -1046,7 +1046,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -1094,13 +1094,13 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.9.2 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1137,10 +1137,10 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.63 +holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 @@ -1149,7 +1149,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.6 # homeassistant.components.horizon horimote==0.4.1 @@ -1205,7 +1205,7 @@ igloohome-api==0.0.6 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort incomfort-client==0.6.4 @@ -1269,7 +1269,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1287,7 +1287,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1301,6 +1301,9 @@ led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.2.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1479,7 +1482,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1516,7 +1519,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1549,7 +1552,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1573,7 +1576,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.3.0 +openwebifpy==4.3.1 # homeassistant.components.luci openwrt-luci-rpc==1.1.17 @@ -1662,7 +1665,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.0.0 +powerfox==1.2.0 # homeassistant.components.reddit praw==7.5.0 @@ -1736,7 +1739,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.3 +py-synologydsm-api==2.6.0 # homeassistant.components.atome pyAtome==0.1.1 @@ -1809,7 +1812,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1887,7 +1890,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1977,10 +1980,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -2076,10 +2079,10 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.22.0 +pylutron-caseta==0.23.0 # homeassistant.components.lutron pylutron==0.2.16 @@ -2109,7 +2112,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.7.4 +pymodbus==3.8.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2121,7 +2124,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==3.0.1 +pynecil==4.0.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2186,13 +2189,13 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.19 # homeassistant.components.elv pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2282,7 +2285,7 @@ pysignalclirestapi==0.3.24 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 @@ -2300,7 +2303,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.1.5 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2324,7 +2327,7 @@ pysqueezebox==0.11.1 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==1.3.5 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2378,7 +2381,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v8.1.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2393,7 +2396,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.0 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay python-linkplay==0.1.1 @@ -2421,7 +2424,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr python-overseerr==0.5.0 @@ -2553,6 +2556,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2584,7 +2590,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.8 +renault-api==0.2.9 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -2659,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.3 # homeassistant.components.sendgrid sendgrid==6.8.2 @@ -2714,7 +2720,7 @@ sisyphus-control==3.1.4 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.xmpp slixmpp==1.8.5 @@ -2723,7 +2729,7 @@ slixmpp==1.8.5 smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 @@ -2738,7 +2744,7 @@ solaredge-local==0.2.3 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2800,7 +2806,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2850,7 +2856,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2862,7 +2868,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -2928,7 +2934,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.2.0 +uiprotect==7.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2969,7 +2975,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -3027,7 +3033,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.1.15 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 @@ -3094,13 +3100,13 @@ yeelightsunflower==0.0.10 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3112,13 +3118,13 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.136.2 +zeroconf==0.140.1 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.44 +zha==0.0.45 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index b3a50bd96a65bb..b6d061577e588b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.15.0a1 +mypy-dev==1.15.0a2 pre-commit==4.0.0 pydantic==2.10.4 pylint==3.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5e0261e146d50..03f85cff99cfec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.0 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -258,7 +258,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -267,7 +267,7 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 @@ -303,7 +303,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -329,7 +329,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -443,7 +443,7 @@ anthemav==1.4.1 anthropic==0.31.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -458,7 +458,7 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.4.0 +aranet4==2.5.0 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -543,7 +543,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -552,7 +552,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 @@ -606,7 +606,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -628,13 +628,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.30.2 # homeassistant.components.debugpy debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -645,7 +645,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -693,7 +693,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.5 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 @@ -705,7 +705,7 @@ elevenlabs==1.9.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -793,7 +793,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 @@ -802,7 +802,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -812,7 +812,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 @@ -846,7 +846,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -896,7 +896,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -935,13 +935,13 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.9.2 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 # homeassistant.components.conversation hassil==2.1.0 @@ -966,10 +966,10 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.63 +holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 @@ -978,7 +978,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1019,7 +1019,7 @@ ifaddr==0.2.0 igloohome-api==0.0.6 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort incomfort-client==0.6.4 @@ -1071,7 +1071,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1086,7 +1086,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1100,6 +1100,9 @@ led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.2.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1242,7 +1245,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1270,7 +1273,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1297,7 +1300,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1315,7 +1318,7 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.enigma2 -openwebifpy==4.3.0 +openwebifpy==4.3.1 # homeassistant.components.opower opower==0.8.7 @@ -1372,7 +1375,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.0.0 +powerfox==1.2.0 # homeassistant.components.reddit praw==7.5.0 @@ -1434,7 +1437,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.3 +py-synologydsm-api==2.6.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 @@ -1489,7 +1492,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1537,7 +1540,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1606,10 +1609,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1690,10 +1693,10 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.22.0 +pylutron-caseta==0.23.0 # homeassistant.components.lutron pylutron==0.2.16 @@ -1717,7 +1720,7 @@ pymicro-vad==1.0.1 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.7.4 +pymodbus==3.8.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1726,7 +1729,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==3.0.1 +pynecil==4.0.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -1782,10 +1785,10 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1811,6 +1814,9 @@ pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.nmbs +pyrail==0.0.3 + # homeassistant.components.rainbird pyrainbird==6.0.1 @@ -1851,7 +1857,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1869,7 +1875,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.1.5 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1890,7 +1896,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.11.1 # homeassistant.components.suez_water -pysuezV2==1.3.5 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1920,7 +1926,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v8.1.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1929,7 +1935,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.0 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay python-linkplay==0.1.1 @@ -1954,7 +1960,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr python-overseerr==0.5.0 @@ -2062,6 +2068,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2084,7 +2093,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.8 +renault-api==0.2.9 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -2138,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.3 # homeassistant.components.emulated_kasa # homeassistant.components.sense @@ -2181,13 +2190,13 @@ simplisafe-python==2024.01.0 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 @@ -2199,7 +2208,7 @@ soco==0.30.6 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2255,7 +2264,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 @@ -2287,7 +2296,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2296,7 +2305,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.lg_thinq thinqconnect==1.0.2 @@ -2350,7 +2359,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.2.0 +uiprotect==7.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2382,7 +2391,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2428,7 +2437,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.1.15 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 @@ -2486,25 +2495,25 @@ yeelight==0.7.14 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zamg zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.136.2 +zeroconf==0.140.1 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.44 +zha==0.0.45 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index dcddf267eb40eb..4dd3bc4601094f 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.8.3 +ruff==0.9.1 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 59ecec939f3730..e2b60e777a290e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,7 +139,7 @@ # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 httpcore==1.0.5 @@ -148,7 +148,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 83d406a0036b07..f842ec61b97af7 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -231,8 +231,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if integrations_path.read_text() != content + "\n": config.add_error( "config_flow", - "File integrations.json is not up to date. " - "Run python3 -m script.hassfest", + "File integrations.json is not up to date. Run python3 -m script.hassfest", fixable=True, ) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 022caee30cd77d..edc47e2f9d717c 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -94,6 +94,8 @@ # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 962ab58d981b02..a64859274d0c65 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,9 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.18,source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ @@ -22,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.3 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index fdbcf5bcb78a55..6e9cd8bdedc049 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -27,6 +27,8 @@ DOCUMENTATION_URL_PATH_PREFIX = "/integrations/" DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} +_CORE_DOCUMENTATION_BASE = "https://www.home-assistant.io/integrations" + class NonScaledQualityScaleTiers(StrEnum): """Supported manifest quality scales.""" @@ -117,19 +119,26 @@ class NonScaledQualityScaleTiers(StrEnum): ] -def documentation_url(value: str) -> str: +def core_documentation_url(value: str) -> str: """Validate that a documentation url has the correct path and domain.""" if value in DOCUMENTATION_URL_EXCEPTIONS: return value + if not value.startswith(_CORE_DOCUMENTATION_BASE): + raise vol.Invalid( + f"Documentation URL does not begin with {_CORE_DOCUMENTATION_BASE}" + ) + return value + + +def custom_documentation_url(value: str) -> str: + """Validate that a custom integration documentation url is correct.""" parsed_url = urlparse(value) if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA: raise vol.Invalid("Documentation url is not prefixed with https") - if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith( - DOCUMENTATION_URL_PATH_PREFIX - ): + if value.startswith(_CORE_DOCUMENTATION_BASE): raise vol.Invalid( - "Documentation url does not begin with www.home-assistant.io/integrations" + "Documentation URL should point to the custom integration documentation" ) return value @@ -258,7 +267,7 @@ def verify_wildcard(value: str) -> str: } ) ], - vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Required("documentation"): vol.All(vol.Url(), core_documentation_url), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -293,6 +302,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { + vol.Required("documentation"): vol.All(vol.Url(), custom_documentation_url), vol.Optional("version"): vol.All(str, verify_version), vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1d7f2b5ed88824..ac27df85ccc597 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -41,7 +41,7 @@ ), "show_error_codes": "true", "follow_imports": "normal", - # "enable_incomplete_feature": ", ".join( # noqa: FLY002 + # "enable_incomplete_feature": ", ".join( # [] # ), # Enable some checks globally. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c24f1d9af26852..7ca7110c49bb7e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -218,7 +218,6 @@ class Rule: "bluetooth_adapters", "bluetooth_le_tracker", "bluetooth_tracker", - "bmw_connected_drive", "bond", "bosch_shc", "braviatv", @@ -653,7 +652,6 @@ class Rule: "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", @@ -742,7 +740,6 @@ class Rule: "omnilogic", "oncue", "ondilo_ico", - "onewire", "onvif", "open_meteo", "openai_conversation", @@ -877,7 +874,6 @@ class Rule: "rtorrent", "rtsp_to_webrtc", "ruckus_unleashed", - "russound_rnet", "ruuvi_gateway", "ruuvitag_ble", "rympro", @@ -1043,7 +1039,6 @@ class Rule: "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", @@ -1121,7 +1116,6 @@ class Rule: "weatherflow_cloud", "weatherkit", "webmin", - "weheat", "wemo", "whirlpool", "whois", @@ -1364,7 +1358,6 @@ class Rule: "directv", "discogs", "discord", - "discovergy", "dlib_face_detect", "dlib_face_identify", "dlink", @@ -1761,7 +1754,6 @@ class Rule: "motioneye", "motionmount", "mpd", - "mqtt", "mqtt_eventstream", "mqtt_json", "mqtt_room", diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py index d11bcaf2cec330..45eaafde0b5fa9 100644 --- a/script/hassfest/quality_scale_validation/discovery.py +++ b/script/hassfest/quality_scale_validation/discovery.py @@ -55,8 +55,7 @@ def validate( config_flow = ast_parse_module(config_flow_file) if not (_has_discovery_function(config_flow)): return [ - f"Integration is missing one of {CONFIG_FLOW_STEPS} " - f"in {config_flow_file}" + f"Integration is missing one of {CONFIG_FLOW_STEPS} in {config_flow_file}" ] return None diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py index c1373032ff8ca9..1f5a5665835651 100644 --- a/script/hassfest/quality_scale_validation/strict_typing.py +++ b/script/hassfest/quality_scale_validation/strict_typing.py @@ -43,7 +43,11 @@ def _check_requirements_are_typed(integration: Integration) -> list[str]: if not any(file for file in distribution.files if file.name == "py.typed"): # no py.typed file - invalid_requirements.append(requirement) + try: + metadata.distribution(f"types-{requirement_name}") + except metadata.PackageNotFoundError: + # also no stubs-only package + invalid_requirements.append(requirement) return invalid_requirements diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2fb70b6e0beff9..b3d397dbd552fe 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -454,7 +454,7 @@ def gen_ha_hardware_schema(config: Config, integration: Integration): ) -def validate_translation_file( # noqa: C901 +def validate_translation_file( config: Config, integration: Integration, all_strings: dict[str, Any] | None, @@ -510,8 +510,8 @@ def validate_translation_file( # noqa: C901 ): integration.add_error( "translations", - "Don't specify title in translation strings if it's a brand " - "name or add exception to ALLOW_NAME_TRANSLATION", + "Don't specify title in translation strings if it's " + "a brand name or add exception to ALLOW_NAME_TRANSLATION", ) if config.specific_integrations: @@ -532,12 +532,15 @@ def validate_translation_file( # noqa: C901 if parts or key not in search: integration.add_error( "translations", - f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", + f"{reference['source']} contains invalid reference" + f"{reference['ref']}: Could not find {key}", ) elif match := re.match(RE_REFERENCE, search[key]): integration.add_error( "translations", - f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + "Lokalise supports only one level of references: " + f'"{reference["source"]}" should point to directly ' + f'to "{match.groups()[0]}"', ) diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index cfa2669ebfe854..d90e01c3ebd522 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -93,7 +93,7 @@ def gather_new_integration(determine_auth: bool) -> Info: "prompt": ( f"""How will your integration gather data? -Valid values are {', '.join(SUPPORTED_IOT_CLASSES)} +Valid values are {", ".join(SUPPORTED_IOT_CLASSES)} More info @ https://developers.home-assistant.io/docs/creating_integration_manifest#iot-class """ diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 0b752e710134d8..11759c48cf3bf6 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with API object # TODO Rename type alias and update all entry annotations @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 9a712834baeb11..66209f77e6a122 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> 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"] == {} with patch( @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -63,7 +63,7 @@ async def test_form_invalid_auth( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} # Make sure the config flow tests finish with either an @@ -83,7 +83,7 @@ async def test_form_invalid_auth( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -114,7 +114,7 @@ async def test_form_cannot_connect( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} # Make sure the config flow tests finish with either an @@ -135,7 +135,7 @@ async def test_form_cannot_connect( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index 06b91f519492f5..ba56b958273781 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -8,7 +8,7 @@ # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] # TODO Create ConfigEntry type alias with API object # Alias name should be prefixed by integration name @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index 8e7854835d8961..fbf705cfb26cda 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -24,7 +24,7 @@ async def test_config_flow( 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 result = await hass.config_entries.flow.async_configure( @@ -33,7 +33,7 @@ async def test_config_flow( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "My NEW_DOMAIN" assert result["data"] == {} assert result["options"] == { @@ -83,7 +83,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: 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["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id @@ -94,7 +94,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "entity_id": input_sensor_2_entity_id, }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "entity_id": input_sensor_2_entity_id, "name": "My NEW_DOMAIN", diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index b84033924719f6..8eaf8b0e25a3d3 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -11,7 +11,7 @@ # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object # TODO Rename type alias and update all entry annotations @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> aiohttp_client.async_get_clientsession(hass), session ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,4 +45,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/split_tests.py b/script/split_tests.py index c64de46a0682c1..0018472e54e87c 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -79,7 +79,7 @@ def create_ouput_file(self) -> None: """Create output file.""" with Path("pytest_buckets.txt").open("w") as file: for idx, bucket in enumerate(self._buckets): - print(f"Bucket {idx+1} has {bucket.total_tests} tests") + print(f"Bucket {idx + 1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index f92f90115cefc7..ac608a1aa0e444 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -70,8 +70,10 @@ def run(): # If we want to only add references to own integrations # but not include entity integrations if ( - args.limit_reference - and (key_integration != key_to_reference_integration and not is_common) + ( + args.limit_reference + and (key_integration != key_to_reference_integration and not is_common) + ) # Do not create self-references in entity integrations or key_integration in Platform.__members__.values() ): diff --git a/script/translations/develop.py b/script/translations/develop.py index 9e3a2ded046dce..00ac7bf98ac493 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -4,7 +4,6 @@ import json from pathlib import Path import re -from shutil import rmtree import sys from . import download, upload @@ -83,9 +82,10 @@ def run_single(translations, flattened_translations, integration): ) if download.DOWNLOAD_DIR.is_dir(): - rmtree(str(download.DOWNLOAD_DIR)) - - download.DOWNLOAD_DIR.mkdir(parents=True) + for lang_file in download.DOWNLOAD_DIR.glob("*.json"): + lang_file.unlink() + else: + download.DOWNLOAD_DIR.mkdir(parents=True) (download.DOWNLOAD_DIR / "en.json").write_text( json.dumps({"component": {integration: translations["component"][integration]}}) diff --git a/tests/common.py b/tests/common.py index ac6f10b8c44ef8..cb4706a97b8b43 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,7 @@ ) from contextlib import asynccontextmanager, contextmanager, suppress from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import Enum, StrEnum import functools as ft from functools import lru_cache from io import StringIO @@ -31,7 +31,6 @@ from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion -from typing_extensions import TypeVar import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -108,20 +107,26 @@ from homeassistant.util.signal_type import SignalType import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=dict[str, Any]) - _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +class QualityScaleStatus(StrEnum): + """Source of core configuration.""" + + DONE = "done" + EXEMPT = "exempt" + TODO = "todo" + + async def async_get_device_automations( hass: HomeAssistant, automation_type: device_automation.DeviceAutomationType, @@ -1189,16 +1194,16 @@ async def mock_psc( yield config if domain is None: - assert ( - len(config) == 1 - ), f"assert_setup_component requires DOMAIN: {list(config.keys())}" + assert len(config) == 1, ( + f"assert_setup_component requires DOMAIN: {list(config.keys())}" + ) domain = list(config.keys())[0] res = config.get(domain) res_len = 0 if res is None else len(res) - assert ( - res_len == count - ), f"setup_component failed, expected {count} got {res_len}: {res}" + assert res_len == count, ( + f"setup_component failed, expected {count} got {res_len}: {res}" + ) def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: @@ -1537,7 +1542,7 @@ def mock_platform( module_cache[platform_path] = module or Mock() -def async_capture_events( +def async_capture_events[_DataT: Mapping[str, Any] = dict[str, Any]]( hass: HomeAssistant, event_name: EventType[_DataT] | str ) -> list[Event[_DataT]]: """Create a helper that captures events.""" @@ -1806,9 +1811,9 @@ async def snapshot_platform( """Snapshot a platform.""" entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) for entity_entry in entity_entries: assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_entry.disabled_by is None, "Please enable all entities." @@ -1832,3 +1837,22 @@ def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: for loaded_components in loaded_categories.values(): for component_to_unload in components: loaded_components.pop(component_to_unload, None) + + +@lru_cache +def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: + """Load quality scale for integration.""" + quality_scale_file = pathlib.Path( + f"homeassistant/components/{integration}/quality_scale.yaml" + ) + if not quality_scale_file.exists(): + return {} + raw = load_yaml_dict(quality_scale_file) + return { + rule: ( + QualityScaleStatus(details) + if isinstance(details, str) + else QualityScaleStatus(details["status"]) + ) + for rule, details in raw["rules"].items() + } diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py index 2c980351c09f57..4a803711959352 100644 --- a/tests/components/acmeda/conftest.py +++ b/tests/components/acmeda/conftest.py @@ -1,5 +1,8 @@ """Define fixtures available for all Acmeda tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + import pytest from homeassistant.components.acmeda.const import DOMAIN @@ -18,3 +21,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) mock_config_entry.add_to_hass(hass) return mock_config_entry + + +@pytest.fixture +def mock_hub_run() -> Generator[AsyncMock]: + """Mock the hub run method.""" + with patch("homeassistant.components.acmeda.hub.aiopulse.Hub.run") as mock_run: + yield mock_run diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 5227d283f250cd..7b92c1aac3b527 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -28,13 +28,6 @@ def mock_hub_discover(): yield mock_discover -@pytest.fixture -def mock_hub_run(): - """Mock the hub run method.""" - with patch("aiopulse.Hub.run") as mock_run: - yield mock_run - - async def async_generator(items): """Async yields items provided in a list.""" for item in items: @@ -56,9 +49,8 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 -async def test_show_form_one_hub( - hass: HomeAssistant, mock_hub_discover, mock_hub_run -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) @@ -102,9 +94,8 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non assert len(mock_hub_discover.mock_calls) == 1 -async def test_create_second_entry( - hass: HomeAssistant, mock_hub_run, mock_hub_discover -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when a second hub is discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py index 0d908ecc9157fe..d5b6997ee33cf9 100644 --- a/tests/components/acmeda/test_cover.py +++ b/tests/components/acmeda/test_cover.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_cover_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index 3d7090ce7dd349..12195d3aec411b 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_sensor_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 941369ff2660b0..3db188bed95c69 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -763,6 +763,57 @@ 'state': '16931', }) # --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_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': 'Raw PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_pm02', + 'unique_id': '84fce612f5b8-pm02_raw', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient Raw PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- # name: test_all_entities[indoor][sensor.airgradient_raw_voc-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 8927947c40e316..01d48e852caaaa 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -10,11 +10,11 @@ ) from homeassistant.components.airgradient.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e053..bb44a0abeb180d 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 072699c7a26ae0..9bc0a8cedbd11c 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -12,7 +12,6 @@ ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -20,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import ( CONFIG, @@ -32,7 +32,7 @@ from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="airzone", ip="192.168.1.100", macaddress=dr.format_mac("E84F25000000").replace(":", ""), diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a9754e..b51dfb890e4cc6 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -214,6 +215,7 @@ API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 263fb69c883398..27997a093e5377 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service NAME = "alert_test" DONE_MESSAGE = "alert_gone" @@ -338,6 +338,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None: """Test that the done message is reset when canceled.""" entity = alert.AlertEntity(hass, *TEST_NOACK) + entity.platform = MockEntityPlatform(hass) entity._cancel = lambda *args: None assert entity._send_done_message is False entity._send_done_message = True diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py index d18e08d4df8fbb..5a52fe52b3a22d 100644 --- a/tests/components/androidtv/test_remote.py +++ b/tests/components/androidtv/test_remote.py @@ -99,9 +99,9 @@ async def test_services_remote(hass: HomeAssistant, config) -> None: "adb_shell", {ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2}, [ - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", ], ) diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 02e15bca41574d..0968ea5acff980 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -6,7 +6,6 @@ from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.androidtv_remote.config_flow import ( APPS_NEW_ID, CONF_APP_DELETE, @@ -22,6 +21,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -444,7 +444,7 @@ async def test_zeroconf_flow_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -522,7 +522,7 @@ async def test_zeroconf_flow_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -573,7 +573,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -657,7 +657,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -710,7 +710,7 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -743,7 +743,7 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -787,7 +787,7 @@ async def test_zeroconf_flow_already_configured_zeroconf_has_multiple_invalid_ip result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.5"), ip_addresses=[ip_address("1.2.3.5"), ip_address(host)], port=6466, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 65ede87728160b..ba290d95ed5eb4 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -127,9 +127,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id="conversation.claude" ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 4567bd32582ea7..a13eb3c605b568 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow from homeassistant.components.apple_tv.const import ( CONF_IDENTIFIERS, @@ -19,12 +18,13 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry -DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( +DMAP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -35,7 +35,7 @@ ) -RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( +RAOP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -566,7 +566,7 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -586,7 +586,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: unrelated_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -601,7 +601,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -883,7 +883,7 @@ async def test_zeroconf_abort_if_other_in_progress( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -906,7 +906,7 @@ async def test_zeroconf_abort_if_other_in_progress( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -933,7 +933,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -955,7 +955,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -992,7 +992,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1014,7 +1014,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1053,7 +1053,7 @@ async def test_zeroconf_pair_additionally_found_protocols( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1096,7 +1096,7 @@ async def test_zeroconf_pair_additionally_found_protocols( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1158,7 +1158,7 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1242,7 +1242,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 7bd00af4837aee..78a1d4aa9c9bbb 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( DISABLED_INTEGRATIONS_SERVICE_INFO, @@ -20,7 +21,11 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None: +async def test_sensors_aranet_radiation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet Radiation device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -73,12 +78,24 @@ async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet☢ 12345" + assert device.model == "Aranet Radiation" + assert device.sw_version == "v1.4.38" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet2(hass: HomeAssistant) -> None: +async def test_sensors_aranet2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet2 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -122,12 +139,24 @@ async def test_sensors_aranet2(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet2_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet2 12345" + assert device.model == "Aranet2" + assert device.sw_version == "v1.4.4" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet4(hass: HomeAssistant) -> None: +async def test_sensors_aranet4( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet4 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -185,12 +214,24 @@ async def test_sensors_aranet4(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet4_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet4 12345" + assert device.model == "Aranet4" + assert device.sw_version == "v1.2.0" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranetrn(hass: HomeAssistant) -> None: +async def test_sensors_aranetrn( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet Radon device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -250,6 +291,14 @@ async def test_sensors_aranetrn(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranetrn_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "AranetRn+ 12345" + assert device.model == "Aranet Radon Plus" + assert device.sw_version == "v1.6.4" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 60c68c5e102f88..1a578fc613de70 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -7,12 +7,21 @@ from arcam.fmj.client import ConnectionFailed import pytest -from homeassistant.components import ssdp from homeassistant.components.arcam_fmj.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_CONFIG_ENTRY, @@ -36,18 +45,18 @@ MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml", upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", - ssdp.ATTR_UPNP_MODEL_NAME: " ", - ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", - ssdp.ATTR_UPNP_SERIAL: "12343", - ssdp.ATTR_UPNP_UDN: MOCK_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", + ATTR_UPNP_MANUFACTURER: "ARCAM", + ATTR_UPNP_MODEL_NAME: " ", + ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", + ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", + ATTR_UPNP_SERIAL: "12343", + ATTR_UPNP_UDN: MOCK_UDN, + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", }, ) @@ -115,7 +124,7 @@ async def test_ssdp_unable_to_connect( async def test_ssdp_invalid_id(hass: HomeAssistant) -> None: """Test a ssdp with invalid UDN.""" discover = replace( - MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"} + MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ATTR_UPNP_UDN: "invalid"} ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f63a28efbb765a..526e1bff1519a6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -44,7 +44,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -135,7 +135,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -226,7 +226,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -341,7 +341,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -446,7 +446,109 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 41747a50eb6a5a..917a9b654d53e2 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -42,7 +42,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -125,7 +125,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -220,7 +220,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -325,7 +325,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -585,7 +585,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -698,7 +698,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -744,7 +744,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index d4cce4e2e9818a..a2cb9ef382aff1 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1102,13 +1102,13 @@ async def async_handle( ) -async def test_pipeline_language_used_instead_of_conversation_language( +async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, snapshot: SnapshotAssertion, ) -> None: - """Test that the pipeline language is used when the conversation language is '*' (all languages).""" + """Test that the STT language is used first when the conversation language is '*' (all languages).""" client = await hass_ws_client(hass) events: list[assist_pipeline.PipelineEvent] = [] @@ -1165,7 +1165,155 @@ async def test_pipeline_language_used_instead_of_conversation_language( assert intent_start is not None - # Pipeline language (en) should be used instead of '*' + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' assert intent_start.data.get("language") == pipeline.language # Check input to async_converse diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 9e9bfd959e6d15..d75cbd072e0658 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -16,7 +16,9 @@ ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid_hex from tests.common import ( MockConfigEntry, @@ -38,11 +40,17 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" - _attr_name = "Test Entity" - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE - - def __init__(self) -> None: + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" + self._attr_unique_id = ulid_hex() + self._attr_device_info = DeviceInfo( + { + "name": name, + "identifiers": {(TEST_DOMAIN, self._attr_unique_id)}, + } + ) + self._attr_name = name + self._attr_supported_features = features self.events = [] self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( @@ -83,7 +91,19 @@ async def async_set_configuration( @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite() + return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity2() -> MockAssistSatellite: + """Mock a second Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity_no_features() -> MockAssistSatellite: + """Mock a third Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity No features", 0) @pytest.fixture @@ -99,6 +119,8 @@ async def init_components( hass: HomeAssistant, config_entry: ConfigEntry, entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, ) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -125,7 +147,9 @@ async def async_unload_entry_init( async_unload_entry=async_unload_entry_init, ), ) - setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + setup_test_component_platform( + hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True + ) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 884ba36782cd6d..0961c7dfbca3f1 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -63,7 +63,7 @@ async def test_entity_state( ) assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None - assert kwargs["device_id"] is None + assert kwargs["device_id"] is entity.device_entry.id assert kwargs["tts_audio_output"] is None assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py new file mode 100644 index 00000000000000..27107c7d2e9555 --- /dev/null +++ b/tests/components/assist_satellite/test_intent.py @@ -0,0 +1,110 @@ +"""Test assist satellite intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from .conftest import MockAssistSatellite + + +@pytest.fixture +def mock_tts(): + """Mock TTS service.""" + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + yield + + +async def test_broadcast_intent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test we can invoke a broadcast intent.""" + + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity", + "name": "Test Entity", + "type": intent.IntentResponseTargetType.ENTITY, + }, + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 1 + assert len(entity_no_features.announcements) == 0 + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) + # Broadcast doesn't targets device that triggered it. + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 2 diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 0036c40a6f25f1..929500f0bb70da 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -82,6 +82,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): options={CONF_CONSIDER_HOME: 60}, unique_id=unique_id, ) + config_entry.add_to_hass(hass) # init variable obj_prefix = slugify(HOST) @@ -131,8 +132,6 @@ async def _test_sensors( disabled_by=None, ) - config_entry.add_to_hass(hass) - # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 4bc5a5d30864fc..0de8d923bb8783 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -123,9 +123,9 @@ async def test_sensors(hass: HomeAssistant, entity_registry: EntityRegistry) -> ] for entity_id, _ in sensors: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index f24eaeb971d821..6dc9118f5112ad 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -2,15 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo AWAIR_UUID = "awair_24947" CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"} LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" -ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.5"), ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 6cc4bbd7c2f755..9dcfbac4e7bf01 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -63,12 +63,12 @@ async def test_camera( assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" assert ( camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" - f"{"" if not stream_profile else f"?{stream_profile}"}" + f"{'' if not stream_profile else f'?{stream_profile}'}" ) assert ( await camera_entity.stream_source() == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" - f"{"" if not stream_profile else f"&{stream_profile}"}" + f"{'' if not stream_profile else f'&{stream_profile}'}" ) diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 52dd9c2f8ad62b..c7c3097aaaaae7 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -6,7 +6,6 @@ import pytest -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( CONF_STREAM_PROFILE, @@ -33,6 +32,9 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DEFAULT_HOST, MAC, MODEL, NAME @@ -268,7 +270,7 @@ async def test_reconfiguration_flow_update_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -276,7 +278,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -312,7 +314,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], port=80, @@ -376,7 +378,7 @@ async def test_discovery_flow( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -384,7 +386,7 @@ async def test_discovery_flow( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -396,7 +398,7 @@ async def test_discovery_flow( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", @@ -431,7 +433,7 @@ async def test_discovered_device_already_configured( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="2.3.4.5", macaddress=DHCP_FORMATTED_MAC, @@ -440,7 +442,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -453,7 +455,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", @@ -507,7 +509,7 @@ async def test_discovery_flow_updated_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="", ip="", macaddress=dr.format_mac("01234567890").replace(":", ""), @@ -515,7 +517,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -527,7 +529,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -556,7 +558,7 @@ async def test_discovery_flow_ignore_non_axis_device( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="169.254.3.4", macaddress=DHCP_FORMATTED_MAC, @@ -564,7 +566,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -576,7 +578,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("169.254.3.4"), ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 74cdb0164cddbe..b2f2d15d989400 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -11,13 +11,14 @@ import pytest from syrupy import SnapshotAssertion -from homeassistant.components import axis, zeroconf +from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import RtspEventMock, RtspStateType from .const import ( @@ -93,7 +94,7 @@ async def test_update_address( mock_requests("2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index ee855fb70f2dd7..7831efeff9a6be 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -9,11 +9,14 @@ import pytest +from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant from .common import TEST_BACKUP_PATH_ABC123 +from tests.common import get_fixture_path + @pytest.fixture(name="mocked_json_bytes") def mocked_json_bytes_fixture() -> Generator[Mock]: @@ -71,7 +74,7 @@ def mock_create_backup() -> Generator[AsyncMock]: mock_written_backup.backup.backup_id = "abc123" mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() - fut = Future() + fut: Future[MagicMock] = Future() fut.set_result(mock_written_backup) with patch( "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup" @@ -113,3 +116,18 @@ def mock_backup_generation_fixture( ), ): yield + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar new file mode 100644 index 00000000000000..8a6556634f3e3c Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/2bcb3113.tar differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar new file mode 100644 index 00000000000000..f3b2845d5eb19b Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f21de9d9fad096..f1208877690dfa 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -83,6 +83,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -112,6 +113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -141,6 +143,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -170,6 +173,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -199,6 +203,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr new file mode 100644 index 00000000000000..fb5d0c276b5296 --- /dev/null +++ b/tests/components/backup/snapshots/test_store.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_store_migration + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 98b2f764d43f3f..8b0ab1317c3402 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -175,7 +175,59 @@ 'type': 'result', }) # --- -# name: test_config_info[None] +# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2] + dict({ + 'error': dict({ + 'code': 'decrypt_not_supported', + 'message': 'Decrypt on download not supported', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-hunter2] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-wrong_password] + dict({ + 'error': dict({ + 'code': 'password_incorrect', + 'message': 'Incorrect password', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup no_such_backup not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[no_such_agent-c0cb53bd-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected: no_such_agent', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data0] dict({ 'id': 1, 'result': dict({ @@ -192,12 +244,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -227,12 +281,14 @@ }), 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -258,12 +314,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -289,12 +347,14 @@ }), 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -320,12 +380,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -351,12 +413,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-16T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'sat', + 'time': None, }), }), }), @@ -381,12 +445,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -412,12 +478,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -450,11 +518,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -475,12 +544,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -506,12 +577,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -544,11 +617,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -569,12 +643,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -600,12 +676,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T06:00:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), @@ -638,11 +716,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -663,12 +742,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -694,12 +775,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -732,11 +815,12 @@ }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -757,12 +841,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -788,12 +874,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -826,11 +914,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -851,12 +940,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -886,12 +977,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -928,11 +1021,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -953,12 +1047,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -984,12 +1080,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1022,11 +1120,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1047,12 +1146,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1078,12 +1179,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1116,11 +1219,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1141,12 +1245,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1172,12 +1278,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1210,11 +1318,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1235,12 +1344,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1266,12 +1377,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1304,11 +1417,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1329,12 +1443,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1360,12 +1476,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1398,11 +1516,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1423,12 +1542,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1453,12 +1574,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1483,12 +1606,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1513,12 +1638,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1543,12 +1670,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1573,12 +1702,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1603,12 +1734,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1633,12 +1766,142 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1656,6 +1919,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1682,6 +1946,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1724,6 +1989,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1750,6 +2016,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1792,6 +2059,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1845,6 +2113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1882,6 +2151,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1930,6 +2200,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1973,6 +2244,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2026,6 +2298,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2080,6 +2353,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2135,6 +2409,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2188,6 +2463,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2241,6 +2517,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2294,6 +2571,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2348,6 +2626,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2792,6 +3071,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2834,6 +3114,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2877,6 +3158,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2941,6 +3223,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2984,6 +3267,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index c071a0d83863a9..b7b86cc1d45376 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,18 +1,23 @@ """Tests for the Backup integration.""" import asyncio -from io import StringIO +from collections.abc import AsyncIterator, Iterable +from io import BytesIO, StringIO +import json +import tarfile +from typing import Any from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration -from tests.common import MockUser +from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -45,8 +50,9 @@ async def test_downloading_remote_backup( hass_client: ClientSessionGenerator, ) -> None: """Test downloading a remote backup.""" - await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + await setup_backup_integration( + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"] + ) client = await hass_client() @@ -54,11 +60,140 @@ async def test_downloading_remote_backup( patch.object(BackupAgentTest, "async_download_backup") as download_mock, ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - resp = await client.get("/api/backup/download/abc123?agent_id=domain.test") + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") assert resp.status == 200 assert await resp.content.read() == b"backup data" +async def test_downloading_local_encrypted_backup_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + client = await hass_client() + + with patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ): + resp = await client.get( + "/api/backup/download/abc123?agent_id=backup.local&password=blah" + ) + assert resp.status == 404 + + +@pytest.mark.usefixtures("mock_backups") +async def test_downloading_local_encrypted_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + await _test_downloading_encrypted_backup(hass_client, "backup.local") + + +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="c0cb53bd", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, + ) + ], + ) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + return aiter_from_iter((backup_path.read_bytes(),)) + + download_mock.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "domain.test") + + +async def _test_downloading_encrypted_backup( + hass_client: ClientSessionGenerator, + agent_id: str, +) -> None: + """Test downloading an encrypted backup file.""" + # Try downloading without supplying a password + client = await hass_client() + resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}") + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, but the inner tar file is encrypted and + # can't be read + with tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar: + enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert enc_metadata["protected"] is True + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + pytest.raises(tarfile.ReadError, match="file could not be opened"), + ): + # pylint: disable-next=consider-using-with + tarfile.open(fileobj=inner_tar_file, mode="r") + + # Download with the wrong password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a truncated outer tar file + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + pytest.raises(tarfile.ReadError, match="unexpected end of data"), + ): + outer_tar.getnames() + + # Finally download with the correct password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, the inner tar file is decrypted and can be read + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + ): + dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert dec_metadata == enc_metadata | {"protected": False} + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, + ): + assert inner_tar.getnames() == [ + ".", + "README.md", + "test_symlink", + "test1", + "test1/script.sh", + ] + + async def test_downloading_backup_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index ad90e2e23bff37..4c7eaf634b3421 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,8 +8,18 @@ from io import StringIO import json from pathlib import Path +import tarfile from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch +from unittest.mock import ( + ANY, + DEFAULT, + AsyncMock, + MagicMock, + Mock, + call, + mock_open, + patch, +) import pytest @@ -33,6 +43,9 @@ CreateBackupStage, CreateBackupState, NewBackup, + ReceiveBackupStage, + ReceiveBackupState, + RestoreBackupState, WrittenBackup, ) from homeassistant.core import HomeAssistant @@ -261,6 +274,7 @@ async def test_async_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -383,7 +397,7 @@ async def test_async_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_data["backup_id"]}.tar" + assert tar_file_path == f"{backup_directory}/{backup_data['backup_id']}.tar" @pytest.mark.usefixtures("mock_backup_generation") @@ -506,6 +520,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id( @@ -600,6 +615,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await hass.async_block_till_done() @@ -670,7 +686,7 @@ async def test_create_backup_success_clears_issue( assert set(issue_registry.issues) == issues_after_create_backup -async def delayed_boom(*args, **kwargs) -> None: +async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: """Raise an exception after a delay.""" async def delayed_boom() -> None: @@ -835,11 +851,6 @@ async def test_async_initiate_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test an unknown or writer upload error during backup generation.""" - hass_storage[DOMAIN] = { - "data": {}, - "key": DOMAIN, - "version": 1, - } agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -872,6 +883,7 @@ async def test_async_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -931,7 +943,7 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - assert not hass_storage[DOMAIN]["data"] + assert DOMAIN not in hass_storage @pytest.mark.usefixtures("mock_backup_generation") @@ -982,6 +994,7 @@ async def test_async_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1086,6 +1099,7 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1429,8 +1443,12 @@ async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, ) -> None: """Test receive backup with a busy manager.""" + new_backup = NewBackup(backup_job_id="time-123") + backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() + create_backup.return_value = (new_backup, backup_task) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -1445,24 +1463,18 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["success"] is True - new_backup = NewBackup(backup_job_id="time-123") - backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() - with patch( - "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", - return_value=(new_backup, backup_task), - ) as create_backup: - await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": ["backup.local"]} - ) - result = await ws_client.receive_json() - assert result["event"] == { - "manager_state": "create_backup", - "stage": None, - "state": "in_progress", - } - result = await ws_client.receive_json() - assert result["success"] is True - assert result["result"] == {"backup_job_id": "time-123"} + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} assert create_backup.call_count == 1 @@ -1488,180 +1500,1012 @@ async def test_receive_backup_busy_manager( await hass.async_block_till_done() -@pytest.mark.parametrize( - ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), - [ - (LOCAL_AGENT_ID, None, True, False, "backups"), - (LOCAL_AGENT_ID, "abc123", False, True, "backups"), - ("test.remote", None, True, True, "tmp_backups"), - ], -) -async def test_async_trigger_restore( +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_receive_backup_agent_error( hass: HomeAssistant, - agent_id: str, - password: str | None, - restore_database: bool, - restore_homeassistant: bool, - dir: str, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, ) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager + """Test upload error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, ), - spec_set=BackupAgentPlatformProtocol, - ), - ) - await manager.load_platforms() + ) - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + client = await hass_client() + ws_client = await hass_ws_client(hass) - with ( - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.open"), - patch("pathlib.Path.write_text") as mocked_write_text, - patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - patch( - "homeassistant.components.backup.manager.validate_password" - ) as validate_password_mock, - patch.object(BackupAgentTest, "async_download_backup") as download_mock, - ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=agent_id, - password=password, - restore_addons=None, - restore_database=restore_database, - restore_folders=None, - restore_homeassistant=restore_homeassistant, - ) - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" - expected_restore_file = json.dumps( - { - "path": backup_path, - "password": password, - "remove_after_restore": agent_id != LOCAL_AGENT_ID, - "restore_database": restore_database, - "restore_homeassistant": restore_homeassistant, - } - ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) - assert mocked_write_text.call_args[0][0] == expected_restore_file - assert mocked_service_call.called + path_glob.return_value = [] + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() -async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None: - """Test trigger restore.""" - password = "hunter2" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] - ), - spec_set=BackupAgentPlatformProtocol, - ), + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} ) - await manager.load_platforms() + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + delete_backup = AsyncMock() + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) with ( - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.write_text") as mocked_write_text, - patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch.object(remote_agent, "async_delete_backup", delete_backup), + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, patch( - "homeassistant.components.backup.manager.validate_password" - ) as validate_password_mock, + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, ): - validate_password_mock.return_value = False - with pytest.raises( - HomeAssistantError, match="The password provided is incorrect." - ): - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=LOCAL_AGENT_ID, - password=password, - restore_addons=None, - restore_database=True, - restore_folders=None, - restore_homeassistant=True, - ) - - backup_path = f"{hass.config.path()}/backups/abc123.tar" - validate_password_mock.assert_called_once_with(Path(backup_path), password) - mocked_write_text.assert_not_called() - mocked_service_call.assert_not_called() - + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() -@pytest.mark.parametrize( - ("parameters", "expected_error"), - [ - ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - "Backup def456 not found", - ), - ( - {"restore_addons": ["blah"]}, - "Addons and folders are not supported in core restore", - ), - ( - {"restore_folders": [Folder.ADDONS]}, - "Addons and folders are not supported in core restore", - ), - ( - {"restore_database": False, "restore_homeassistant": False}, - "Home Assistant or database must be included in restore", - ), - ], -) -async def test_async_trigger_restore_wrong_parameters( - hass: HomeAssistant, parameters: dict[str, Any], expected_error: str -) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await manager.load_platforms() + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } - default_parameters = { - "agent_id": LOCAL_AGENT_ID, - "backup_id": TEST_BACKUP_ABC123.backup_id, - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.COMPLETED, } - with ( - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.write_text") as mocked_write_text, - patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - pytest.raises(HomeAssistantError, match=expected_error), + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 1 + assert delete_backup.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")]) +async def test_receive_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test non agent upload error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert DOMAIN not in hass_storage + assert resp.status == 500 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None), + (1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_receive_backup_file_write_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, +) -> None: + """Test file write error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", + [ + OSError("Boom!"), + tarfile.TarError("Boom!"), + json.JSONDecodeError("Boom!", "test", 1), + KeyError("Boom!"), + ], +) +async def test_receive_backup_read_tar_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + exception: Exception, +) -> None: + """Test read tar error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=exception, + ) as read_backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert read_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + "final_state", + "response_status", + ), + [ + ( + 2, + [DEFAULT, OSError("Boom!")], + 0, + None, + 1, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + 2, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, OSError("Boom!")], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + ReceiveBackupState.FAILED, + 500, + ), + ], +) +async def test_receive_backup_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: list[Exception | None], + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + unlink_call_count: int, + unlink_exception: Exception | None, + final_state: ReceiveBackupState, + response_status: int, +) -> None: + """Test file read error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": final_state, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == response_status + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + [ + (LOCAL_AGENT_ID, {}, True, False, "backups"), + (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), + ("test.remote", {}, True, True, "tmp_backups"), + ], +) +async def test_restore_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + password_param: dict[str, str], + restore_database: bool, + restore_homeassistant: bool, + dir: str, +) -> None: + """Test restore backup.""" + password = password_param.get("password") + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + | password_param + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.CORE_RESTART, + } + + # Note: The core restart is not tested here, in reality the following events + # are not sent because the core restart closes the WS connection. + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + expected_restore_file = json.dumps( + { + "path": backup_path, + "password": password, + "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ) + validate_password_mock.assert_called_once_with(Path(backup_path), password) + assert mocked_write_text.call_args[0][0] == expected_restore_file + assert mocked_service_call.called + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "dir"), [(LOCAL_AGENT_ID, "backups"), ("test.remote", "tmp_backups")] +) +async def test_restore_backup_wrong_password( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + dir: str, +) -> None: + """Test restore backup wrong password.""" + password = "hunter2" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + validate_password_mock.return_value = False + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "password": password, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "password_incorrect" + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + validate_password_mock.assert_called_once_with(Path(backup_path), password) + mocked_write_text.assert_not_called() + mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ( + {"backup_id": TEST_BACKUP_DEF456.backup_id}, + f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + ), + ( + {"restore_addons": ["blah"]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_folders": [Folder.ADDONS]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_database": False, "restore_homeassistant": False}, + "Home Assistant or database must be included in restore", + ), + ], +) +async def test_restore_backup_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test restore backup wrong parameters.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), ): - await manager.async_restore_backup(**(default_parameters | parameters)) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + | parameters + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error mocked_write_text.assert_not_called() mocked_service_call.assert_not_called() diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py new file mode 100644 index 00000000000000..d240e21531df08 --- /dev/null +++ b/tests/components/backup/test_store.py @@ -0,0 +1,54 @@ +"""Tests for the Backup integration.""" + +from typing import Any + +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + + +async def test_store_migration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test migrating the backup store.""" + hass_storage[DOMAIN] = { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + } + await setup_backup_integration(hass) + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 307a1d79e0cad7..29ce4dc485ee19 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -14,6 +14,7 @@ BackupAgentPlatformProtocol, BackupReaderWriterError, Folder, + store, ) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN @@ -53,7 +54,7 @@ ) DEFAULT_STORAGE_DATA: dict[str, Any] = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": [], @@ -70,9 +71,7 @@ "copies": None, "days": None, }, - "schedule": { - "state": "never", - }, + "schedule": {"state": "never", "time": None}, }, } @@ -305,7 +304,8 @@ async def test_delete_with_errors( hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} @@ -906,118 +906,153 @@ async def test_agents_info( @pytest.mark.parametrize( "storage_data", [ - None, + {}, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_all_addons": True, - "include_database": True, - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": 3, "days": 7}, + "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "daily", "time": None}, + }, }, - "retention": {"copies": 3, "days": 7}, - "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily"}, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": 3, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "never", "time": None}, + }, }, - "retention": {"copies": 3, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "never"}, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": 7}, + "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "never", "time": None}, + }, }, - "retention": {"copies": None, "days": 7}, - "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never"}, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "mon", "time": None}, + }, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "mon"}, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "sat", "time": None}, + }, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "sat"}, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], storage_data: dict[str, Any] | None, ) -> None: """Test getting backup config info.""" - hass_storage[DOMAIN] = { - "data": storage_data, - "key": DOMAIN, - "version": 1, - } + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1034,17 +1069,17 @@ async def test_config_info( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily", "time": "06:00"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "type": "backup/config/update", @@ -1055,59 +1090,63 @@ async def test_config_info( "name": "test-name", "password": "test-password", }, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, command: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1120,6 +1159,11 @@ async def test_config_update( assert await client.receive_json() == snapshot await hass.async_block_till_done() + # Trigger store write + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot @@ -1130,7 +1174,17 @@ async def test_config_update( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "someday", + "schedule": "blah", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"time": "early"}, }, { "type": "backup/config/update", @@ -1179,6 +1233,7 @@ async def test_config_update_errors( "time_2", "attempted_backup_time", "completed_backup_time", + "scheduled_backup_time", "backup_calls_1", "backup_calls_2", "call_args", @@ -1189,10 +1244,11 @@ async def test_config_update_errors( # No config update [], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1204,14 +1260,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1222,14 +1279,53 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-25T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-25T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "mon", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-25T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "daily", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-13T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", 1, 2, BACKUP_CALL, @@ -1240,7 +1336,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, } ], "2024-11-11T04:45:00+01:00", @@ -1248,6 +1344,7 @@ async def test_config_update_errors( "2034-11-11T13:00:00+01:00", "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", + None, 0, 0, None, @@ -1258,14 +1355,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1276,14 +1374,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", 1, 1, BACKUP_CALL, @@ -1294,7 +1393,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, } ], "2024-10-26T04:45:00+01:00", @@ -1302,6 +1401,7 @@ async def test_config_update_errors( "2034-11-12T12:00:00+01:00", "2024-10-26T04:45:00+01:00", "2024-10-26T04:45:00+01:00", + None, 0, 0, None, @@ -1312,14 +1412,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1330,14 +1431,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1345,6 +1447,7 @@ async def test_config_update_errors( ), ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1357,6 +1460,7 @@ async def test_config_schedule_logic( time_2: str, attempted_backup_time: str, completed_backup_time: str, + scheduled_backup_time: str, backup_calls_1: int, backup_calls_2: int, call_args: Any, @@ -1365,7 +1469,7 @@ async def test_config_schedule_logic( """Test config schedule logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test.test-agent"], @@ -1379,13 +1483,14 @@ async def test_config_schedule_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": last_completed_automatic_backup, "last_completed_automatic_backup": last_completed_automatic_backup, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } create_backup.side_effect = create_backup_side_effect await hass.config.async_set_time_zone("Europe/Amsterdam") @@ -1399,6 +1504,10 @@ async def test_config_schedule_logic( result = await client.receive_json() assert result["success"] + await client.send_json_auto_id({"type": "backup/info"}) + result = await client.receive_json() + assert result["result"]["next_automatic_backup"] == scheduled_backup_time + freezer.move_to(time_1) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1444,7 +1553,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1483,7 +1592,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1522,7 +1631,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1551,7 +1660,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1595,7 +1704,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1639,7 +1748,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1678,7 +1787,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1717,7 +1826,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1761,7 +1870,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1787,6 +1896,7 @@ async def test_config_schedule_logic( ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_retention_copies_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1810,7 +1920,7 @@ async def test_config_retention_copies_logic( """Test config backup retention copies logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -1824,13 +1934,14 @@ async def test_config_retention_copies_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -1894,7 +2005,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1930,7 +2041,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1966,7 +2077,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2007,7 +2118,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2067,7 +2178,7 @@ async def test_config_retention_copies_logic_manual_backup( """Test config backup retention copies logic for manual backup.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -2081,13 +2192,14 @@ async def test_config_retention_copies_logic_manual_backup( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -2208,7 +2320,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2244,7 +2356,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2280,7 +2392,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 3}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2316,7 +2428,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2357,7 +2469,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2393,7 +2505,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2429,7 +2541,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 0}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2487,7 +2599,7 @@ async def test_config_retention_days_logic( """Test config backup retention logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -2501,13 +2613,14 @@ async def test_config_retention_days_logic( "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -2552,3 +2665,41 @@ async def test_subscribe_event( CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) ) assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + ("agent_id", "backup_id", "password"), + [ + # Invalid agent or backup + ("no_such_agent", "c0cb53bd", "hunter2"), + ("backup.local", "no_such_backup", "hunter2"), + # Legacy backup, which can't be streamed + ("backup.local", "2bcb3113", "hunter2"), + # New backup, which can be streamed, try with correct and wrong password + ("backup.local", "c0cb53bd", "hunter2"), + ("backup.local", "c0cb53bd", "wrong_password"), + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + agent_id: str, + backup_id: str, + password: str, +) -> None: + """Test can decrypt on download.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": backup_id, + "agent_id": agent_id, + "password": password, + } + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 765801d22cfb45..14810f67ce31d8 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -4,11 +4,11 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.baf.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_NAME, MOCK_UUID, MockBAFDevice @@ -90,7 +90,7 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -128,7 +128,7 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -148,7 +148,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -167,7 +167,7 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 27292e5a28cb75..c21afb4a130eea 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -31,8 +31,8 @@ CONF_BEOLINK_JID, BangOlufsenSource, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "192.168.0.1" TEST_HOST_INVALID = "192.168.0" @@ -42,18 +42,18 @@ TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" -TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" -TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" -TEST_FRIENDLY_NAME_2 = "Laundry room Balance" -TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" +TEST_FRIENDLY_NAME_2 = "Laundry room Core" +TEST_SERIAL_NUMBER_2 = "22222222" +TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222" TEST_HOST_2 = "192.168.0.2" TEST_FRIENDLY_NAME_3 = "Lego room Balance" @@ -84,7 +84,7 @@ CONF_NAME: TEST_NAME, } TEST_DATA_CREATE_ENTRY_2 = { - CONF_HOST: TEST_HOST, + CONF_HOST: TEST_HOST_2, CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, CONF_NAME: TEST_NAME_2, diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr new file mode 100644 index 00000000000000..3b748d3a27a218 --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_button_event_creation + list([ + 'event.beosound_balance_11111111_bluetooth', + 'event.beosound_balance_11111111_microphone', + 'event.beosound_balance_11111111_next', + 'event.beosound_balance_11111111_play_pause', + 'event.beosound_balance_11111111_favourite_1', + 'event.beosound_balance_11111111_favourite_2', + 'event.beosound_balance_11111111_favourite_3', + 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_previous', + 'event.beosound_balance_11111111_volume', + 'media_player.beosound_balance_11111111', + ]) +# --- +# name: test_button_event_creation_beoconnect_core + list([ + 'media_player.beoconnect_core_22222222', + ]) +# --- diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 327b7ecfacf9d6..be7989a2cb926f 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -642,7 +642,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -661,7 +661,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -737,7 +737,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -756,7 +756,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -831,7 +831,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -850,7 +850,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -924,7 +924,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -943,7 +943,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1003,7 +1003,7 @@ 'attributes': ReadOnlyDict({ 'beolink': dict({ 'leader': dict({ - 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com', }), 'peers': dict({ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', @@ -1017,7 +1017,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'media_player.beosound_balance_11111111', ]), 'media_content_type': , @@ -1062,7 +1062,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -1081,7 +1081,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index d58e5d2219b2cf..855dab40db1197 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -4,6 +4,7 @@ from inflection import underscore from mozart_api.models import ButtonEvent +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bang_olufsen.const import ( DEVICE_BUTTON_EVENTS, @@ -25,6 +26,7 @@ async def test_button_event_creation( mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are created.""" @@ -35,14 +37,21 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" + "preset", "favourite_" ) for button_type in DEVICE_BUTTONS ] # Check that the entities are available for entity_id in entity_ids: - entity_registry.async_get(entity_id) + assert entity_registry.async_get(entity_id) + + # Check number of entities + # The media_player entity and all of the button event entities should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 + len(entity_ids) + + assert entity_ids_available == snapshot async def test_button_event_creation_beoconnect_core( @@ -50,6 +59,7 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are not created when using a Beoconnect Core.""" @@ -57,17 +67,12 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_core.entry_id) - # Add Button Event entity ids - entity_ids = [ - f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" - ) - for button_type in DEVICE_BUTTONS - ] + # Check number of entities + # The media_player entity should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 - # Check that the entities are unavailable - for entity_id in entity_ids: - assert not entity_registry.async_get(entity_id) + assert entity_ids_available == snapshot async def test_button( diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index db92dddcc77cfe..5de41a1fb1e772 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -10,7 +10,6 @@ from homeassistant.components.blackbird.media_player import ( DATA_BLACKBIRD, PLATFORM_SCHEMA, - setup_platform, ) from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -18,6 +17,9 @@ ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockEntityPlatform class AttrDict(dict): @@ -181,21 +183,21 @@ async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) -> "homeassistant.components.blackbird.media_player.get_blackbird", return_value=mock_blackbird, ): - await hass.async_add_executor_job( - setup_platform, + await async_setup_component( hass, + "media_player", { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "zones": {3: {"name": "Zone name"}}, - "sources": { - 1: {"name": "one"}, - 3: {"name": "three"}, - 2: {"name": "two"}, - }, + "media_player": { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "zones": {3: {"name": "Zone name"}}, + "sources": { + 1: {"name": "one"}, + 3: {"name": "three"}, + 2: {"name": "two"}, + }, + } }, - lambda *args, **kwargs: None, - {}, ) await hass.async_block_till_done() @@ -207,6 +209,7 @@ def media_player_entity( """Return the media player entity.""" media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) media_player.entity_id = "media_player.zone_3" return media_player @@ -271,10 +274,6 @@ async def test_update( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test updating values from blackbird.""" - assert media_player_entity.state is None - assert media_player_entity.source is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON assert media_player_entity.source == "one" @@ -291,9 +290,6 @@ async def test_state( mock_blackbird: MockBlackbird, ) -> None: """Test state property.""" - assert media_player_entity.state is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON mock_blackbird.zones[3].power = False @@ -315,8 +311,6 @@ async def test_source( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test source property.""" - assert media_player_entity.source is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.source == "one" @@ -324,8 +318,6 @@ async def test_media_title( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test media title property.""" - assert media_player_entity.media_title is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.media_title == "one" diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 612c4f09424e17..4b0c1b23e79db7 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -7,12 +7,12 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.blebox import config_flow from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .conftest import mock_config, mock_feature, mock_only_feature, setup_product_mock @@ -227,7 +227,7 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -267,7 +267,7 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -291,7 +291,7 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -317,7 +317,7 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 3e644d3038a9d4..f71302f286d43c 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -9,7 +9,6 @@ 'media_artist': 'artist', 'media_content_type': , 'media_duration': 123, - 'media_position': 2, 'media_title': 'song', 'shuffle': False, 'source_list': list([ diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index 63744cdf0ffc03..d0e0f75991b1ee 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -5,11 +5,11 @@ from pyblu.errors import PlayerUnreachableError from homeassistant.components.bluesound.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import PlayerMocks @@ -113,63 +113,6 @@ async def test_user_flow_aleady_configured( player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() -async def test_import_flow_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks -) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "player-name1111" - assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000} - assert result["result"].unique_id == "ff:ff:01:01:01:01-11000" - - mock_setup_entry.assert_called_once() - player_mocks.player_data.player.sync_status.assert_called_once() - - -async def test_import_flow_cannot_connect( - hass: HomeAssistant, player_mocks: PlayerMocks -) -> None: - """Test we handle cannot connect error.""" - player_mocks.player_data.player.sync_status.side_effect = PlayerUnreachableError( - "Player not reachable" - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - player_mocks.player_data.player.sync_status.assert_called_once() - - -async def test_import_flow_already_configured( - hass: HomeAssistant, - player_mocks: PlayerMocks, - config_entry: MockConfigEntry, -) -> None: - """Test we handle already configured.""" - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.2", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() - - async def test_zeroconf_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks ) -> None: diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index a43696a0a7f3fb..ed537d0bc57b41 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -127,7 +127,9 @@ async def test_attributes_set( ) -> None: """Test the media player attributes set.""" state = hass.states.get("media_player.player_name1111") - assert state == snapshot(exclude=props("media_position_updated_at")) + assert state == snapshot( + exclude=props("media_position_updated_at", "media_position") + ) async def test_stop_maps_to_idle( diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 8794d808718387..c672de7424bdb7 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -14,7 +14,9 @@ from homeassistant.components.bluetooth import ( DOMAIN, + MONOTONIC_TIME, SOURCE_LOCAL, + BaseHaRemoteScanner, BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, @@ -25,17 +27,17 @@ from tests.common import MockConfigEntry __all__ = ( + "MockBleakClient", + "generate_advertisement_data", + "generate_ble_device", "inject_advertisement", "inject_advertisement_with_source", "inject_advertisement_with_time_and_source", "inject_advertisement_with_time_and_source_connectable", "inject_bluetooth_service_info", "patch_all_discovered_devices", - "patch_discovered_devices", - "generate_advertisement_data", - "generate_ble_device", - "MockBleakClient", "patch_bluetooth_time", + "patch_discovered_devices", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -324,3 +326,26 @@ def discovered_devices_and_advertisement_data( ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} + + +class FakeRemoteScanner(BaseHaRemoteScanner): + """Fake remote scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 93a1c59cba1b51..1be39bfaa944ef 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,6 +8,12 @@ import habluetooth.util as habluetooth_utils import pytest +# pylint: disable-next=no-name-in-module +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from . import FakeScanner + @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): @@ -304,3 +310,23 @@ def disable_new_discovery_flows_fixture(): "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" ) as mock_create_flow: yield mock_create_flow + + +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci0 scanner.""" + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci0_scanner.source) + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci1 scanner.""" + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci1_scanner.source) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index abfbbaa15ab68e..e3bdca256c0c8f 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -7,16 +7,12 @@ from typing import Any from unittest.mock import patch -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData - # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, storage, @@ -28,12 +24,16 @@ SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from . import ( + FakeRemoteScanner as FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data, @@ -41,30 +41,7 @@ patch_bluetooth_time, ) -from tests.common import async_fire_time_changed, load_fixture - - -class FakeScanner(BaseHaRemoteScanner): - """Fake scanner.""" - - def inject_advertisement( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - now: float | None = None, - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - now or MONOTONIC_TIME(), - ) +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @pytest.mark.parametrize("name_2", [None, "w"]) @@ -545,3 +522,75 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: cancel() unsetup() + + +@pytest.mark.usefixtures("enable_bluetooth") +@pytest.mark.parametrize( + ("manufacturer", "source"), + [ + ("test", "test"), + ("Raspberry Pi Trading Ltd (test)", "28:CD:C1:11:23:45"), + ], +) +async def test_remote_scanner_bluetooth_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + manufacturer: str, + source: str, +) -> None: + """Test the remote scanner gets a bluetooth config entry.""" + manager: HomeAssistantBluetoothManager = _get_manager() + + switchbot_device = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(source, source, connector, True) + unsetup = scanner.async_setup() + assert scanner.source == source + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + cancel = manager.async_register_hass_scanner( + scanner, + source_domain="test", + source_model="test", + source_config_entry_id=entry.entry_id, + ) + await hass.async_block_till_done() + + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + assert len(scanner.discovered_devices) == 1 + + cancel() + unsetup() + + adapter_entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + assert adapter_entry is not None + assert adapter_entry.state is ConfigEntryState.LOADED + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert dev is not None + assert dev.config_entries == {adapter_entry.entry_id} + assert dev.manufacturer == manufacturer + + manager.async_remove_scanner(scanner.source) + await hass.async_block_till_done() + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0a0cb3fa8e0696..abb3a5e2393686 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -6,16 +6,23 @@ import pytest from homeassistant import config_entries +from homeassistant.components.bluetooth import HaBluetoothConnector from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component +from . import FakeRemoteScanner, MockBleakClient, _get_manager + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -450,6 +457,36 @@ async def test_options_flow_enabled_linux( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: + """Test options are not available for remote adapters.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "BB:BB:BB:BB:BB:BB", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "remote_adapters_not_supported" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" @@ -467,3 +504,49 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_adapters" assert result["description_placeholders"] == {"ignored_adapters": "1"} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter( + hass: HomeAssistant, +) -> None: + """Test remote adapter configuration via integration discovery.""" + entry = MockConfigEntry(domain="test") + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + manager = _get_manager() + cancel_scanner = manager.async_register_scanner(scanner) + + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "esp32" + assert result["data"] == { + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + } + assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + cancel_scanner() + await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index ba8792a79a3afa..2c8c9e70e7f8b7 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -18,6 +18,7 @@ BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, + HaBluetoothConnector, async_process_advertisements, async_rediscover_address, async_track_unavailable, @@ -25,11 +26,16 @@ from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.components.bluetooth.match import ( ADDRESS, CONNECTABLE, @@ -46,7 +52,9 @@ from homeassistant.util import dt as dt_util from . import ( + FakeRemoteScanner, FakeScanner, + MockBleakClient, _get_manager, async_setup_with_default_adapter, async_setup_with_one_adapter, @@ -3022,6 +3030,23 @@ async def test_scanner_count_connectable(hass: HomeAssistant) -> None: cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_remove(hass: HomeAssistant) -> None: + """Test permanently removing a scanner.""" + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) + assert bluetooth.async_scanner_count(hass, connectable=True) == 1 + device = generate_ble_device("44:44:33:11:23:45", "name") + adv = generate_advertisement_data(local_name="name", service_uuids=[]) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), scanner.source, True + ) + cancel() + bluetooth.async_remove_scanner(hass, scanner.source) + manager: HomeAssistantBluetoothManager = _get_manager() + assert not manager.storage.async_get_advertisement_history(scanner.source) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_scanner_count(hass: HomeAssistant) -> None: """Test getting the connectable and non-connectable scanner count.""" @@ -3245,3 +3270,33 @@ async def test_title_updated_if_mac_address( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_cleanup_orphened_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries get cleaned up when orphened.""" + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: "no_longer_exists", + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Orphened remote scanner config entry should be cleaned up + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0454df9a4a791a..77071368dd0d33 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,6 +7,7 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from freezegun import freeze_time # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -36,10 +36,12 @@ SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( @@ -63,24 +65,6 @@ ) -@pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) - yield - cancel() - - -@pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) - yield - cancel() - - @pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, @@ -1660,3 +1644,71 @@ def clear_all_devices(self) -> None: cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_register_disappeared_callback( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test bluetooth async_register_disappeared_callback handles failures.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + failed_disappeared: list[str] = [] + + def _failing_callback(_address: str) -> None: + """Failing callback.""" + failed_disappeared.append(_address) + raise ValueError("This is a test") + + ok_disappeared: list[str] = [] + + def _ok_callback(_address: str) -> None: + """Ok callback.""" + ok_disappeared.append(_address) + + manager: HomeAssistantBluetoothManager = _get_manager() + cancel1 = manager.async_register_disappeared_callback(_failing_callback) + # Make sure the second callback still works if the first one fails and + # raises an exception + cancel2 = manager.async_register_disappeared_callback(_ok_callback) + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + + assert len(ok_disappeared) == 1 + assert ok_disappeared[0] == address + assert len(failed_disappeared) == 1 + assert failed_disappeared[0] == address + + cancel1() + cancel2() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index d7a7a8ba08c06a..e9274965e3cb47 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1808,6 +1808,7 @@ def _mock_update_method( sensor_entity: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity.hass = hass + sensor_entity.platform = MockEntityPlatform(hass) assert sensor_entity.available is True assert sensor_entity.name is UNDEFINED assert sensor_entity.device_class is SensorDeviceClass.TEMPERATURE diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py new file mode 100644 index 00000000000000..c9670f2f8956e2 --- /dev/null +++ b/tests/components/bluetooth/test_websocket_api.py @@ -0,0 +1,116 @@ +"""The tests for the bluetooth WebSocket API.""" + +import asyncio +from datetime import timedelta +import time +from unittest.mock import ANY, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import ( + generate_advertisement_data, + generate_ble_device, + inject_advertisement_with_source, +) + +from tests.common import async_fire_time_changed +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_advertisements( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_advertisements.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_advertisements", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {}, + "name": "wohand_signal_100", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "hci0", + "time": ANY, + "tx_power": -127, + } + ] + } + adv_time = response["event"]["add"][0]["time"] + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {"123": "616263"}, + "name": "wohand_signal_100", + "rssi": -80, + "service_data": {}, + "service_uuids": [], + "source": "hci1", + "time": ANY, + "tx_power": -127, + } + ] + } + new_time = response["event"]["add"][0]["time"] + assert new_time > adv_time + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index c437e1d3669c73..2cd65364604001 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -53,6 +53,13 @@ "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway" ) +BIMMER_CONNECTED_LOGIN_PATCH = ( + "homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login" +) +BIMMER_CONNECTED_VEHICLE_PATCH = ( + "homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles" +) + async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9c124261392ff1..2d4b1390ccc105 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -15,11 +15,13 @@ CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + BIMMER_CONNECTED_LOGIN_PATCH, + BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CAPTCHA_INPUT, FIXTURE_CONFIG_ENTRY, FIXTURE_GCID, @@ -40,40 +42,59 @@ def login_sideeffect(self: MyBMWAuthentication): self.gcid = FIXTURE_GCID -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW authentication error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAuthError("Login failed"), +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), + data=deepcopy(FIXTURE_USER_INPUT), ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) + + assert len(mock_setup_entry.mock_calls) == 1 -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW API error.""" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (MyBMWAuthError("Login failed"), "invalid_auth"), + (RequestError("Connection reset"), "cannot_connect"), + (MyBMWAPIError("400 Bad Request"), "cannot_connect"), + ], +) +async def test_error_display_with_successful_login( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we show user form on MyBMW authentication error and are still able to succeed.""" with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=RequestError("Connection reset"), + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -83,25 +104,63 @@ async def test_connection_error(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + deepcopy(FIXTURE_USER_INPUT), + ) -async def test_api_error(hass: HomeAssistant) -> None: - """Test we show user form on general connection error.""" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAPIError("400 Bad Request"), + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_id_existing(hass: HomeAssistant) -> None: + """Test registering an integration and when the unique id already exists.""" + + mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + mock_config_entry.add_to_hass(hass) + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), + data=deepcopy(FIXTURE_USER_INPUT), ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("bmw_fixture") @@ -126,44 +185,11 @@ async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "missing_captcha"} -async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow works.""" - with ( - patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=login_sideeffect, - autospec=True, - ), - patch( - "homeassistant.components.bmw_connected_drive.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "captcha" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_CAPTCHA_INPUT - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] - assert result["data"] == FIXTURE_COMPLETE_ENTRY - - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_options_flow_implementation(hass: HomeAssistant) -> None: """Test config flow options.""" with ( patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ), patch( @@ -200,7 +226,7 @@ async def test_reauth(hass: HomeAssistant) -> None: """Test the reauth form.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -249,7 +275,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_reconfigure(hass: HomeAssistant) -> None: """Test the reconfiguration form.""" with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ): diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index beb3d74d572014..2e317ec133440d 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,7 +1,6 @@ -"""Test BMW coordinator.""" +"""Test BMW coordinator for general availability/unavailability of entities and raising issues.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import patch from bimmer_connected.models import ( @@ -13,27 +12,56 @@ import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.const import ( + CONF_REFRESH_TOKEN, + SCAN_INTERVALS, +) from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.update_coordinator import UpdateFailed -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed +FIXTURE_ENTITY_STATES = { + "binary_sensor.m340i_xdrive_door_lock_state": "off", + "lock.m340i_xdrive_lock": "locked", + "lock.i3_rex_lock": "unlocked", + "number.ix_xdrive50_target_soc": "80", + "sensor.ix_xdrive50_rear_left_tire_pressure": "2.61", + "sensor.ix_xdrive50_rear_right_tire_pressure": "2.69", +} +FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION] + @pytest.mark.usefixtures("bmw_fixture") -async def test_update_success(hass: HomeAssistant) -> None: - """Test the reauth form.""" - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) +async def test_config_entry_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if the coordinator updates the refresh token in config entry.""" + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token" + config_entry = MockConfigEntry(**config_entry_fixure) config_entry.add_to_hass(hass) + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "old_token" + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.runtime_data.last_update_success is True + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "another_token_string" + ) @pytest.mark.usefixtures("bmw_fixture") @@ -41,125 +69,176 @@ async def test_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, ) -> None: - """Test the reauth form.""" + """Test a failing API call.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data - - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + # On API error, entities should be unavailable + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAPIError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state @pytest.mark.usefixtures("bmw_fixture") -async def test_update_reauth( +async def test_auth_failed_as_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a single auth failure not initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data - - assert coordinator.last_update_success is True + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - freezer.tick(timedelta(minutes=5, seconds=1)) + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" - freezer.tick(timedelta(minutes=5, seconds=1)) - with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWAuthError("Test error"), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done() + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + + # Verify that no issues are raised and no reauth flow is initialized + assert len(issue_registry.issues) == 0 + assert len(hass.config_entries.flow.async_progress_by_handler(BMW_DOMAIN)) == 0 @pytest.mark.usefixtures("bmw_fixture") -async def test_init_reauth( +async def test_auth_failed_init_reauth( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a two subsequent auth failures initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + assert len(issue_registry.issues) == 0 + + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + with patch( + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWAuthError("Test error"), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" assert len(issue_registry.issues) == 0 + # On second failure, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): - await hass.config_entries.async_setup(config_entry.entry_id) + async_fire_time_changed(hass) await hass.async_block_till_done() + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + reauth_issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id + @pytest.mark.usefixtures("bmw_fixture") async def test_captcha_reauth( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" - TEST_REGION = "north_america" - - config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_fixure["data"][CONF_REGION] = TEST_REGION - config_entry = MockConfigEntry(**config_entry_fixure) + """Test a CaptchaError initializing reauth flow.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data - - assert coordinator.last_update_success is True + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - freezer.tick(timedelta(minutes=10, seconds=1)) + # If library decides a captcha is needed, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True - assert coordinator.last_exception.translation_key == "missing_captcha" + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + + reauth_issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, + f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", + ) + assert reauth_issue.active is True + + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index 8507cacc376456..d0624825cb5e9a 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -212,7 +212,7 @@ async def test_dont_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 53c39f572f2a2c..878edefac27d81 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -138,13 +138,6 @@ async def test_service_call_invalid_input( HomeAssistantError, REMOTE_SERVICE_EXC_TRANSLATION, ), - ( - ServiceValidationError( - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit" - ), - ServiceValidationError, - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit", - ), ], ) async def test_service_call_fail( diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index d61ed4844a1316..73aece4af6b720 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -10,12 +10,12 @@ from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( patch_bond_bridge, @@ -219,7 +219,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -260,7 +260,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -302,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -349,7 +349,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -393,7 +393,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -437,7 +437,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -475,7 +475,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -522,7 +522,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -561,7 +561,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.3"), ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", @@ -583,7 +583,7 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: await _help_test_form_unexpected_error( hass, source=config_entries.SOURCE_ZEROCONF, - initial_input=zeroconf.ZeroconfServiceInfo( + initial_input=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 63f7169b02659f..06fd5b9102cd7b 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -13,11 +13,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bosch_shc.config_flow import write_tls_asset from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -25,7 +25,7 @@ "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host"}, } -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", @@ -615,7 +615,7 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None: """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 7a4f93f7f1696a..497e88053f5f06 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -10,7 +10,6 @@ ) import pytest -from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( CONF_NICKNAME, CONF_USE_PSK, @@ -22,6 +21,12 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import instance_id +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -46,14 +51,14 @@ {"title": "AV/Component", "uri": "extInput:component?port=1"}, ] -BRAVIA_SSDP = ssdp.SsdpServiceInfo( +BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://bravia-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV", - ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Living TV", + ATTR_UPNP_MODEL_NAME: "KE-55XH9096", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": [ @@ -68,14 +73,14 @@ }, ) -FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( +FAKE_BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://soundbar-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", - ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", + ATTR_UPNP_MODEL_NAME: "HT-S700RF", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 62aa38d4e920ef..7d1b787ff0beee 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -8,7 +8,7 @@ from bring_api.types import BringAuthResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from tests.common import MockConfigEntry, load_json_object_fixture @@ -43,6 +43,7 @@ def mock_bring_client() -> Generator[AsyncMock]: ): client = mock_client.return_value client.uuid = UUID + client.mail = EMAIL client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 5ee66999ea4201..8c215e024d5b33 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -1,21 +1,22 @@ """Unit tests for the bring integration.""" +from datetime import timedelta from unittest.mock import AsyncMock +from bring_api import BringAuthException, BringParseException, BringRequestException +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.bring import ( - BringAuthException, - BringParseException, - BringRequestException, - async_setup_entry, -) +from homeassistant.components.bring import async_setup_entry from homeassistant.components.bring.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .conftest import UUID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def setup_integration( @@ -115,13 +116,20 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize( - "exception", [None, BringAuthException, BringRequestException, BringParseException] + ("exception", "state"), + [ + (None, ConfigEntryState.LOADED), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], ) async def test_config_entry_not_ready_auth_error( hass: HomeAssistant, bring_config_entry: MockConfigEntry, mock_bring_client: AsyncMock, exception: Exception | None, + state: ConfigEntryState, ) -> None: """Test config entry not ready from authentication error.""" @@ -132,4 +140,33 @@ async def test_config_entry_not_ready_auth_error( await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() - assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + assert bring_config_entry.state is state + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_skips_deactivated( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the coordinator skips fetching lists for deactivated lists.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert mock_bring_client.get_list.await_count == 2 + + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{UUID}_b4776778-7f6c-496e-951b-92a35d3db0dd")} + ) + device_registry.async_update_device(device.id, disabled_by=ConfigEntryDisabler.USER) + + mock_bring_client.get_list.reset_mock() + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_bring_client.get_list.await_count == 1 diff --git a/tests/components/bring/test_notification.py b/tests/components/bring/test_notification.py index b1fa28335ad17d..711598d3f4be02 100644 --- a/tests/components/bring/test_notification.py +++ b/tests/components/bring/test_notification.py @@ -65,7 +65,7 @@ async def test_send_notification_exception( mock_bring_client.notify.side_effect = BringRequestException with pytest.raises( HomeAssistantError, - match="Failed to send push notification for bring due to a connection error, try again later", + match="Failed to send push notification for Bring! due to a connection error, try again later", ): await hass.services.async_call( DOMAIN, @@ -94,7 +94,7 @@ async def test_send_notification_service_validation_error( with pytest.raises( HomeAssistantError, match=re.escape( - "Failed to perform action bring.send_message. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "This action requires field item, please enter a valid value for item" ), ): await hass.services.async_call( diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 0d9ed0c53456ba..88379530362fd2 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -5,7 +5,7 @@ from bring_api import BringUserSettingsResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index f31cb3806316f6..14e41bbff19c95 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -8,10 +8,10 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_device @@ -828,7 +828,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress=device.mac, @@ -862,7 +862,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -881,7 +881,7 @@ async def test_dhcp_unreachable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -900,7 +900,7 @@ async def test_dhcp_connect_unknown_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -922,7 +922,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip=device.host, macaddress=device.mac, @@ -946,7 +946,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -971,7 +971,7 @@ async def test_dhcp_updates_host(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="4.5.6.7", macaddress="34ea34b43b5a", diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py index 2d4eb8e0e0b632..7e3ae4efcabade 100644 --- a/tests/components/broadlink/test_switch.py +++ b/tests/components/broadlink/test_switch.py @@ -92,7 +92,7 @@ async def test_slots_switch_setup_works( for slot, switch in enumerate(switches): assert ( hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] - == f"{device.name} S{slot+1}" + == f"{device.name} S{slot + 1}" ) assert hass.states.get(switch.entity_id).state == STATE_OFF assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 929e2f083e9216..945f5549bbedd7 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -6,12 +6,12 @@ from brother import SnmpError, UnsupportedModelError import pytest -from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -121,7 +121,7 @@ async def test_zeroconf_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -145,7 +145,7 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -171,7 +171,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -224,7 +224,7 @@ async def test_zeroconf_confirm_create_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py index 8d01db6e0158e4..fc184ae2ef503e 100644 --- a/tests/components/cambridge_audio/test_config_flow.py +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -6,11 +6,11 @@ from aiostreammagic import StreamMagicError from homeassistant.components.cambridge_audio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index a3045e27cf1db3..5a26e3b44f6e51 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -41,6 +41,7 @@ from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( + MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -826,6 +827,30 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3b4868b56ac67c..ef7a99453f08af 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -179,7 +179,7 @@ async def test_alexa_config_invalidate_token( assert await async_setup_component(hass, "homeassistant", {}) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -192,7 +192,7 @@ async def test_alexa_config_invalidate_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -239,7 +239,7 @@ async def test_alexa_config_fail_refresh_token( ) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -256,7 +256,7 @@ async def test_alexa_config_fail_refresh_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -286,7 +286,7 @@ async def test_alexa_config_fail_refresh_token( conf.async_invalidate_access_token() aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={"reason": reject_reason}, status=400, ) @@ -312,7 +312,7 @@ async def test_alexa_config_fail_refresh_token( # State reporting should now be re-enabled for Alexa aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5d9513a1d1bf9f..112e71ec2dbd74 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -204,6 +204,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } @@ -389,6 +390,7 @@ async def test_agents_upload_fail_put( aioclient_mock: AiohttpClientMocker, mock_get_upload_details: Mock, put_mock_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" client = await hass_client() @@ -417,6 +419,9 @@ async def test_agents_upload_fail_put( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + patch("homeassistant.components.cloud.backup.random.randint", return_value=60), + patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup @@ -426,6 +431,8 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 2 + assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 @@ -469,6 +476,7 @@ async def test_agents_upload_fail_cloud( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index b152309b24a685..cb456be503669e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -32,7 +32,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -264,6 +264,7 @@ async def test_google_entity_registry_sync( @pytest.mark.usefixtures("mock_cloud_login") async def test_google_device_registry_sync( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, cloud_prefs: CloudPreferences, ) -> None: @@ -275,8 +276,14 @@ async def test_google_device_registry_sync( # Enable exposing new entities to Google expose_new(hass, True) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( - "light", "hue", "1234", device_id="1234" + "light", "hue", "1234", device_id=device_entry.id ) entity_entry = entity_registry.async_update_entity( entity_entry.entity_id, area_id="ABCD" @@ -294,7 +301,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["manufacturer"], }, ) @@ -308,7 +315,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["area_id"], }, ) @@ -324,7 +331,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["area_id"], }, ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d915f158af0158..910fa03d46cdb8 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -112,7 +112,6 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", - "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ad123cded844d7..9a6d4abfc9352a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -45,7 +45,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", }, @@ -62,7 +61,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket" assert cl.accounts_server == "test-acounts-server" assert cl.cloudhook_server == "test-cloudhook-server" - assert cl.alexa_server == "test-alexa-server" assert cl.acme_server == "test-acme-server" assert cl.remotestate_server == "test-remotestate-server" diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 03a8272e586073..81c696bc6a7c79 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -7,6 +7,13 @@ from pytest_unordered import unordered from homeassistant.components.config import area_registry +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar from homeassistant.util.dt import utcnow @@ -24,10 +31,32 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test list entries.""" created_area1 = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -39,10 +68,12 @@ async def test_list_areas( area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, - icon="mdi:garage", - picture="/image/example.png", floor_id="first_floor", + humidity_entity_id="sensor.mock_humidity", + icon="mdi:garage", labels={"label_1", "label_2"}, + picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", ) await client.send_json_auto_id({"type": "config/area_registry/list"}) @@ -52,24 +83,28 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "created_at": created_area1.timestamp(), "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], + "modified_at": created_area1.timestamp(), "name": "mock 1", "picture": None, - "created_at": created_area1.timestamp(), - "modified_at": created_area1.timestamp(), + "temperature_entity_id": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "created_at": created_area2.timestamp(), "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), + "modified_at": created_area2.timestamp(), "name": "mock 2", "picture": "/image/example.png", - "created_at": created_area2.timestamp(), - "modified_at": created_area2.timestamp(), + "temperature_entity_id": "sensor.mock_temperature", }, ] @@ -78,6 +113,7 @@ async def test_create_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test create entry.""" # Create area with only mandatory parameters @@ -97,6 +133,8 @@ async def test_create_area( "picture": None, "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": None, + "humidity_entity_id": None, } assert len(area_registry.areas) == 1 @@ -109,12 +147,15 @@ async def test_create_area( "labels": ["label_1", "label_2"], "name": "mock 2", "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", "alias_2"]), "area_id": ANY, @@ -125,6 +166,8 @@ async def test_create_area( "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) == 2 @@ -185,6 +228,7 @@ async def test_update_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test update entry.""" created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -195,14 +239,16 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_2"], "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", - "type": "config/area_registry/update", + "temperature_entity_id": "sensor.mock_temperature", } ) @@ -212,10 +258,12 @@ async def test_update_area( "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } @@ -226,13 +274,15 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_1"], "area_id": area.id, "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], "picture": None, - "type": "config/area_registry/update", + "temperature_entity_id": None, } ) @@ -246,6 +296,8 @@ async def test_update_area( "labels": [], "name": "mock 2", "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 6c937473ddc7d3..a4faab483eefe2 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -14,9 +14,9 @@ async def test_request_least_info(hass: HomeAssistant) -> None: """Test request config with least amount of data.""" request_id = configurator.async_request_config(hass, "Test Request", lambda _: None) - assert ( - len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1 - ), "No new service registered" + assert len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1, ( + "No new service registered" + ) states = hass.states.async_all() diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 534c471bf8381b..490f8e3dabc5c4 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -7,6 +7,7 @@ from functools import lru_cache from importlib.util import find_spec from pathlib import Path +import re import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -42,6 +43,8 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.util import yaml +from tests.common import QualityScaleStatus, get_quality_scale + if TYPE_CHECKING: from homeassistant.components.hassio import AddonManager @@ -51,6 +54,9 @@ from .sensor.common import MockSensor from .switch.common import MockSwitch +# Regex for accessing the integration name from the test path +RE_REQUEST_DOMAIN = re.compile(r".*tests\/components\/([^/]+)\/.*") + @pytest.fixture(scope="session", autouse=find_spec("zeroconf") is not None) def patch_zeroconf_multiple_catcher() -> Generator[None]: @@ -74,9 +80,15 @@ def prevent_io() -> Generator[None]: @pytest.fixture def entity_registry_enabled_by_default() -> Generator[None]: """Test fixture that ensures all entities are enabled in the registry.""" - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - return_value=True, + with ( + patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ), + patch( + "homeassistant.components.device_tracker.config_entry.ScannerEntity.entity_registry_enabled_default", + return_value=True, + ), ): yield @@ -765,7 +777,7 @@ async def _check_config_flow_result_translations( translation_errors, category, integration, - f"{key_prefix}abort.{result["reason"]}", + f"{key_prefix}abort.{result['reason']}", result["description_placeholders"], ) @@ -798,12 +810,29 @@ async def _check_create_issue_translations( ) +def _get_request_quality_scale( + request: pytest.FixtureRequest, rule: str +) -> QualityScaleStatus: + if not (match := RE_REQUEST_DOMAIN.match(str(request.path))): + return QualityScaleStatus.TODO + integration = match.groups(1)[0] + return get_quality_scale(integration).get(rule, QualityScaleStatus.TODO) + + async def _check_exception_translation( hass: HomeAssistant, exception: HomeAssistantError, translation_errors: dict[str, str], + request: pytest.FixtureRequest, ) -> None: if exception.translation_key is None: + if ( + _get_request_quality_scale(request, "exception-translations") + is QualityScaleStatus.DONE + ): + translation_errors["quality_scale"] = ( + f"Found untranslated {type(exception).__name__} exception: {exception}" + ) return await _validate_translation( hass, @@ -817,13 +846,14 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], + ignore_translations: str | list[str], request: pytest.FixtureRequest ) -> AsyncGenerator[None]: """Check that translation requirements are met. Current checks: - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) - issue registry entries + - action (service) exceptions """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] @@ -881,7 +911,9 @@ async def _service_registry_async_call( ) except HomeAssistantError as err: translation_coros.add( - _check_exception_translation(self._hass, err, translation_errors) + _check_exception_translation( + self._hass, err, translation_errors, request + ) ) raise diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index f1e220b10b2caa..c2b16ea29127d6 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -26,7 +26,7 @@ # --- # name: test_custom_sentences.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -51,7 +51,7 @@ # --- # name: test_custom_sentences_config dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -76,7 +76,7 @@ # --- # name: test_intent_alias_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -106,7 +106,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -136,7 +136,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -156,7 +156,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -176,7 +176,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -206,7 +206,7 @@ # --- # name: test_intent_entity_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -236,7 +236,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -266,7 +266,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -296,7 +296,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -316,7 +316,7 @@ # --- # name: test_intent_entity_exposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -346,7 +346,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -366,7 +366,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -386,7 +386,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -416,7 +416,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -436,7 +436,7 @@ # --- # name: test_intent_entity_renamed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -466,7 +466,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 0de575790db8b1..1102a41e6c3df2 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -201,7 +201,7 @@ # --- # name: test_http_api_handle_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -221,7 +221,7 @@ # --- # name: test_http_api_no_match dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -241,7 +241,7 @@ # --- # name: test_http_api_unexpected_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -261,7 +261,7 @@ # --- # name: test_http_processing_intent[None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -291,7 +291,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -321,7 +321,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -351,7 +351,7 @@ # --- # name: test_ws_api[payload0] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -371,7 +371,7 @@ # --- # name: test_ws_api[payload1] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -391,7 +391,7 @@ # --- # name: test_ws_api[payload2] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -411,7 +411,7 @@ # --- # name: test_ws_api[payload3] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -431,7 +431,7 @@ # --- # name: test_ws_api[payload4] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -451,7 +451,7 @@ # --- # name: test_ws_api[payload5] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 0327be064d487e..911c7043a6d3df 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ - 'conversation_id': 'test-conv-id', + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -44,7 +44,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -74,7 +74,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -104,7 +104,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -134,7 +134,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -164,7 +164,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -194,7 +194,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -224,7 +224,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -254,7 +254,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -284,7 +284,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -314,7 +314,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -344,7 +344,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -374,7 +374,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_session.ambr new file mode 100644 index 00000000000000..4e94157c6019ee --- /dev/null +++ b/tests/components/conversation/snapshots/test_session.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_template_error + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I had a problem with my template', + }), + }), + }), + }) +# --- +# name: test_unknown_llm_api + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/conversation/test_agent_manager.py b/tests/components/conversation/test_agent_manager.py index 47b58a522a8fbd..3f98c9bcd69d3f 100644 --- a/tests/components/conversation/test_agent_manager.py +++ b/tests/components/conversation/test_agent_manager.py @@ -22,6 +22,7 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None: language="test lang", agent_id="conversation.home_assistant", device_id="test device id", + extra_system_prompt="test extra prompt", ) assert mock_process.called @@ -32,3 +33,4 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None: assert conversation_input.language == "test lang" assert conversation_input.agent_id == "conversation.home_assistant" assert conversation_input.device_id == "test device id" + assert conversation_input.extra_system_prompt == "test extra prompt" diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 7e05476a3496d3..80a056a6ea0b7a 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -399,9 +399,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: result = await conversation.async_converse(hass, sentence, None, Context()) assert callback.call_count == 1 assert callback.call_args[0][0].text == sentence - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), sentence + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + sentence + ) assert result.response.speech == { "plain": {"speech": trigger_response, "extra_data": None} } @@ -412,9 +412,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: callback.reset_mock() for sentence in test_sentences: result = await conversation.async_converse(hass, sentence, None, Context()) - assert ( - result.response.response_type == intent.IntentResponseType.ERROR - ), sentence + assert result.response.response_type == intent.IntentResponseType.ERROR, ( + sentence + ) assert len(callback.mock_calls) == 0 @@ -3104,3 +3104,51 @@ async def test_turn_on_off( ) assert len(off_calls) == 1 assert off_calls[0].data.get("entity_id") == [entity_id] + + +@pytest.mark.parametrize( + ("error_code", "return_response"), + [ + (intent.IntentResponseErrorCode.NO_INTENT_MATCH, False), + (intent.IntentResponseErrorCode.NO_VALID_TARGETS, False), + (intent.IntentResponseErrorCode.FAILED_TO_HANDLE, True), + (intent.IntentResponseErrorCode.UNKNOWN, True), + ], +) +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_with_response_errors( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, + error_code: intent.IntentResponseErrorCode, + return_response: bool, +) -> None: + """Test that handle_intents does not return response errors.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + return_value=default_agent._make_error_result( + user_input.language, error_code, "Mock error message" + ), + ) as mock_process: + response = await agent.async_handle_intents(user_input) + + assert len(mock_process.mock_calls) == 1 + + if return_response: + assert response is not None and response.error_code == error_code + else: + assert response is None diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py new file mode 100644 index 00000000000000..feb6ca2a9e840f --- /dev/null +++ b/tests/components/conversation/test_session.py @@ -0,0 +1,415 @@ +"""Test the conversation session.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.conversation import ConversationInput, session +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: + """Return a conversation input instance.""" + return ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.mark.parametrize( + ("start_id", "given_id"), + [ + (None, "mock-ulid"), + # This ULID is not known as a session + ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), + ("not-a-ulid", "not-a-ulid"), + ], +) +async def test_conversation_id( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + mock_ulid: Mock, + start_id: str | None, + given_id: str, +) -> None: + """Test conversation ID generation.""" + mock_conversation_input.conversation_id = start_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id == given_id + + +async def test_cleanup( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Mock cleanup of the conversation session.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + conversation_id = chat_session.conversation_id + + # Generate session entry. + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + # Because we didn't add a message to the session in the last block, + # the conversation was not be persisted and we get a new ID + assert chat_session.conversation_id != conversation_id + conversation_id = chat_session.conversation_id + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + assert len(chat_session.messages) == 3 + + # Reuse conversation ID to ensure we can chat with same session + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + # Set the last updated to be older than the timeout + hass.data[session.DATA_CHAT_HISTORY][conversation_id].last_updated = ( + dt_util.utcnow() + session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1) + ) + + # Should not be cleaned up, but it should have scheduled another cleanup + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1) + ) + + # It should be cleaned up now and we start a new conversation + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id != conversation_id + assert len(chat_session.messages) == 2 + + +def test_chat_message() -> None: + """Test chat message.""" + with pytest.raises(ValueError): + session.ChatMessage(role="native", agent_id=None, content="", native=None) + + +async def test_add_message( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="system", agent_id=None, content="") + ) + + # No 2 user messages in a row + assert chat_session.messages[1].role == "user" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="user", agent_id=None, content="") + ) + + # No 2 assistant messages in a row + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + assert len(chat_session.messages) == 3 + assert chat_session.messages[-1].role == "assistant" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + + +async def test_message_filtering( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + messages = chat_session.async_get_messages(agent_id=None) + assert len(messages) == 2 + assert messages[0] == session.ChatMessage( + role="system", + agent_id=None, + content="", + ) + assert messages[1] == session.ChatMessage( + role="user", + agent_id=mock_conversation_input.agent_id, + content=mock_conversation_input.text, + ) + # Cannot add a second user message in a row + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage( + role="user", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + ) + # Different agent, will be filtered out. + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="another-mock-agent-id", content="", native=1 + ) + ) + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) + ) + + assert len(chat_session.messages) == 5 + + messages = chat_session.async_get_messages(agent_id="mock-agent-id") + assert len(messages) == 4 + + assert messages[2] == session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + assert messages[3] == session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) + + +async def test_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + assert isinstance(chat_session.llm_api, llm.APIInstance) + assert chat_session.llm_api.api.id == "assist" + + +async def test_unknown_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an LLM API that does not exists.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="unknown-api", + user_llm_prompt=None, + ) + + assert str(exc_info.value) == "Error getting LLM API unknown-api" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_error( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test that template error handling works.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt="{{ invalid_syntax", + ) + + assert str(exc_info.value) == "Error rendering prompt" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_variables( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that template variables work.""" + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + mock_conversation_input.context = Context(user_id=mock_user.id) + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with patch( + "homeassistant.auth.AuthManager.async_get_user", return_value=mock_user + ): + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=( + "The instance name is {{ ha_name }}. " + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + "The calling platform is {{ llm_context.platform }}." + ), + ) + + assert chat_session.user_name == "Test User" + + assert "The instance name is test home." in chat_session.messages[0].content + assert "The user name is Test User." in chat_session.messages[0].content + assert "The user id is 12345." in chat_session.messages[0].content + assert "The calling platform is test." in chat_session.messages[0].content + + +async def test_extra_systen_prompt( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that extra system prompt works.""" + extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." + extra_system_prompt2 = ( + "User person.paulus came home. Asked him what he wants to do." + ) + mock_conversation_input.extra_system_prompt = extra_system_prompt + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.conversation_id = chat_session.conversation_id + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that we take new system prompts + mock_conversation_input.extra_system_prompt = extra_system_prompt2 + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_session.messages[0].content + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 50fac51c87a4a5..9b57bb43b58ed0 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -88,6 +88,7 @@ async def test_if_fires_on_event( "device_id": None, "language": "en", "text": "Ha ha ha", + "extra_system_prompt": None, }, } @@ -235,6 +236,7 @@ async def test_response_same_sentence( "device_id": None, "language": "en", "text": "test sentence", + "extra_system_prompt": None, }, } @@ -412,6 +414,7 @@ async def test_same_trigger_multiple_sentences( "device_id": None, "language": "en", "text": "hello", + "extra_system_prompt": None, }, } @@ -639,6 +642,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) "device_id": None, "language": "en", "text": "play the white album by the beatles", + "extra_system_prompt": None, }, } diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 66c2064eb3abde..a14bc28537911f 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -21,6 +21,8 @@ COUNTRY = "CH" LANGUAGE = "de-CH" +TEST_UUID = "sub_uuid" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,16 +36,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_cookidoo_client() -> Generator[AsyncMock]: """Mock a Cookidoo client.""" - with ( - patch( - "homeassistant.components.cookidoo.Cookidoo", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.cookidoo.config_flow.Cookidoo", - new=mock_client, - ), - ): + with patch( + "homeassistant.components.cookidoo.helpers.Cookidoo", + autospec=True, + ) as mock_client: client = mock_client.return_value client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) client.get_ingredient_items.return_value = [ @@ -58,7 +54,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] - client.login.return_value = None + client.login.return_value = CookidooAuthResponse( + **load_json_object_fixture("login.json", DOMAIN) + ) yield client @@ -67,6 +65,8 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: """Mock cookidoo configuration entry.""" return MockConfigEntry( domain=DOMAIN, + version=1, + minor_version=2, data={ CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD, @@ -74,4 +74,5 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: CONF_LANGUAGE: LANGUAGE, }, entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, ) diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json new file mode 100644 index 00000000000000..e7bd6e8716c7af --- /dev/null +++ b/tests/components/cookidoo/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJhbGci", + "expires_in": 43199, + "refresh_token": "eyJhbGciOiJSUzI1NiI", + "token_type": "bearer", + "sub": "sub_uuid" +} diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index 60f9e95bee7953..a6223059aa192e 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'todo_clear', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_todo_clear', + 'unique_id': 'sub_uuid_todo_clear', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index 965cbb0addec9e..be641432929ac2 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'additional_item_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items', + 'unique_id': 'sub_uuid_additional_items', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'ingredient_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients', + 'unique_id': 'sub_uuid_ingredients', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 0057bb3767e6a2..069442517a04c8 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -200,7 +200,12 @@ async def test_flow_reconfigure_success( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + user_input={ + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, ) assert result["type"] is FlowResultType.FORM @@ -215,6 +220,8 @@ async def test_flow_reconfigure_success( assert result["reason"] == "reconfigure_successful" assert cookidoo_config_entry.data == { **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", } @@ -340,6 +347,35 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( assert len(hass.config_entries.async_entries()) == 1 +async def test_flow_reconfigure_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new config is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + async def test_flow_reauth( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, @@ -419,46 +455,26 @@ async def test_flow_reauth_error_and_recover( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.parametrize( - ("new_email", "saved_email", "result_reason"), - [ - (EMAIL, EMAIL, "reauth_successful"), - ("another-email", EMAIL, "already_configured"), - ], -) -async def test_flow_reauth_init_data_already_configured( +async def test_flow_reauth_id_mismatch( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, - new_email: str, - saved_email: str, - result_reason: str, ) -> None: - """Test we abort user data set when entry is already configured.""" + """Test we abort when the new auth is not for the same user.""" cookidoo_config_entry.add_to_hass(hass) - - another_cookidoo_config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_EMAIL: "another-email", - CONF_PASSWORD: PASSWORD, - CONF_COUNTRY: COUNTRY, - CONF_LANGUAGE: LANGUAGE, - }, + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" ) - another_cookidoo_config_entry.add_to_hass(hass) - result = await cookidoo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD}, + {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == result_reason - assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index b1b9b880526ccc..e97bf93bb213e6 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -7,9 +7,18 @@ from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID from tests.common import MockConfigEntry @@ -100,3 +109,229 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + + +MOCK_CONFIG_ENTRY_MIGRATION = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, +} + +OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID" + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + ), + (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID), + ], +) +async def test_migration_from( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version}", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + # Check change in config entry and verify most recent version + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == TEST_UUID + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{TEST_UUID}_todo_clear", + ) + ) + ) + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + "login_exception", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooRequestException, + ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooAuthException, + ), + ], +) +async def test_migration_from_with_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + login_exception: Exception, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths but with connection issues.""" + # Migration can fail due to connection issues as we have to fetch the uuid + mock_cookidoo_client.login.side_effect = login_exception + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{OLD_ENTRY_ID}_todo_clear", + ) + ) + ) diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index e6021d2232671e..a6c10d4acf1be2 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -766,10 +766,7 @@ async def test_if_fires_on_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] @@ -925,10 +922,7 @@ async def test_if_fires_on_tilt_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index e43b64b16a79ad..f1997066638bce 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,6 +2,8 @@ from enum import Enum +import pytest + from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -11,7 +13,11 @@ from .common import MockCover -from tests.common import help_test_all, setup_test_component_platform +from tests.common import ( + MockEntityPlatform, + help_test_all, + setup_test_component_platform, +) async def test_services( @@ -153,3 +159,24 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) + + +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 5c432e111dd665..612ae7ab649fee 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -7,12 +7,12 @@ from pydaikin.exceptions import DaikinException import pytest -from homeassistant.components import zeroconf from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -121,7 +121,7 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: [ ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="mock_hostname", diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ce13bbfa5d47c4..fe5fe022427d67 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -6,7 +6,6 @@ import pydeconz import pytest -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import ( CONF_MANUAL_INPUT, CONF_SERIAL, @@ -20,12 +19,16 @@ DOMAIN as DECONZ_DOMAIN, HASSIO_CONFIGURATION_URL, ) -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .conftest import API_KEY, BRIDGE_ID @@ -435,7 +438,7 @@ async def test_flow_ssdp_discovery( """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -483,7 +486,7 @@ async def test_ssdp_discovery_update_configuration( ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.3.4.5:80/", @@ -509,7 +512,7 @@ async def test_ssdp_discovery_dont_update_configuration( result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -533,7 +536,7 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration( """Test to ensure the SSDP discovery does not update an Hass.io entry.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 43c511793376e7..1b000828b8560b 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -6,18 +6,18 @@ import pytest from syrupy import SnapshotAssertion -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, - ATTR_UPNP_UDN, -) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import BRIDGE_ID @@ -81,7 +81,7 @@ async def test_update_address( ): await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_st="mock_st", ssdp_usn="mock_usn", ssdp_location="http://2.3.4.5:80/", diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 324b795052c60b..92fe381ac4dab1 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -5,7 +5,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.denonavr.config_flow import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -21,6 +20,12 @@ from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -313,14 +318,14 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -353,14 +358,14 @@ async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: "NotSupported", + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -377,12 +382,12 @@ async def test_config_flow_ssdp_missing_info(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, }, ), ) @@ -399,14 +404,14 @@ async def test_config_flow_ssdp_ignored_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py index 28ab6229c447b0..08ccaffa92df26 100644 --- a/tests/components/devialet/__init__.py +++ b/tests/components/devialet/__init__.py @@ -5,10 +5,10 @@ from aiohttp import ClientError as ServerTimeoutError from devialet.const import UrlSuffix -from homeassistant.components import zeroconf from homeassistant.components.devialet.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +25,7 @@ MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} MOCK_USER_INPUT = {CONF_HOST: HOST} -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="PhantomISilver-L00P00000AB11.local.", diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index e73c18919c503c..6226669aa0fa5e 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -159,9 +159,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: ] legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device MAC" in args[0], "Duplicate MAC warning expected" @@ -177,9 +177,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device IDs" in args[0], "Duplicate device IDs warning expected" diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 3351e42c98836f..06e7a8bcd9cd3f 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], port=14791, @@ -22,7 +22,7 @@ }, ) -DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", @@ -32,7 +32,7 @@ type="mock_type", ) -DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 7b0551b1daf5f7..f3c469e61b2d8a 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -14,7 +14,7 @@ ) from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo IP = "192.0.2.1" IP_ALT = "192.0.2.2" diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 6852f4369ccb3e..9f3435f0cd95df 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -34,6 +34,7 @@ import homeassistant.helpers.device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -41,6 +42,7 @@ MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) @@ -1353,3 +1355,30 @@ async def test_dhcp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "DhcpServiceInfo", + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + DhcpServiceInfo, + ), + ], +) +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, + dhcp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index ae22e280000660..48a334611d3184 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -2,10 +2,10 @@ from http import HTTPStatus -from homeassistant.components import ssdp from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -16,7 +16,7 @@ UPNP_SERIAL = "RID-028877455858" MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ad22aa871b7234..b698873d1e94c9 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -6,11 +6,11 @@ from aiohttp import ClientError as HTTPClientError from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL from . import ( HOST, diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index c56b93c4d3d3dc..d59e06ef444f52 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -6,11 +6,11 @@ import pytest -from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,13 +29,13 @@ CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", ) -CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW_NEW_IP = DhcpServiceInfo( ip="5.6.7.8", macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index b6f025bb5b02a3..0449f68263cc12 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( CONF_DATA, @@ -160,7 +160,7 @@ async def test_dhcp_unique_id_assignment( hass: HomeAssistant, mocked_plug: MagicMock ) -> None: """Test dhcp initialized flow with no unique id for matching entry.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="2.3.4.5", macaddress="11:22:33:44:55:66", hostname="dsp-w215", diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index cb32001e1e5ad1..e02baceb380de2 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -12,7 +12,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, @@ -23,6 +22,15 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST_ADDR, @@ -48,17 +56,17 @@ MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR}, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/AVTransport/scpd.xml", @@ -358,15 +366,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -492,15 +500,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -514,7 +522,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -526,7 +534,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -538,10 +546,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" ] } @@ -560,10 +568,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy() + service_list = discovery.upnp[ATTR_UPNP_SERVICE_LIST].copy() # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -589,9 +597,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] = ( - "urn:schemas-upnp-org:device:ZonePlayer:1" - ) + discovery.upnp[ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -608,8 +614,8 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ): discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer - discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model + discovery.upnp[ATTR_UPNP_MANUFACTURER] = manufacturer + discovery.upnp[ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 3d8f9da8ed9b7a..a92f78079129c4 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.setup import async_setup_component from .conftest import ( @@ -1413,7 +1414,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1484,7 +1485,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1506,7 +1507,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1525,7 +1526,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1546,7 +1547,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -1554,7 +1555,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1597,7 +1598,7 @@ async def create_device_delayed(_location): # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1606,7 +1607,7 @@ async def create_device_delayed(_location): ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1637,7 +1638,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1656,7 +1657,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1689,7 +1690,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1702,7 +1703,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1727,7 +1728,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1752,7 +1753,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1777,7 +1778,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -1816,7 +1817,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1829,7 +1830,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1854,7 +1855,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -1893,7 +1894,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1913,7 +1914,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1933,7 +1934,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -2354,7 +2355,7 @@ async def test_connections_restored( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 14da36a038171d..76890f328e4a4c 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -12,11 +12,17 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST, @@ -35,16 +41,16 @@ MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" -MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY: Final = SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/ContentDirectory/scpd.xml", @@ -195,15 +201,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -279,7 +285,7 @@ async def test_duplicate_name( ssdp_udn=new_device_udn, ) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_UDN] = new_device_udn + discovery.upnp[ATTR_UPNP_UDN] = new_device_udn result = await hass.config_entries.flow.async_init( DOMAIN, @@ -312,15 +318,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -334,7 +340,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -346,7 +352,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -358,10 +364,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # ContentDirectory service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:ContentDirectory" ] } @@ -380,10 +386,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]) + service_list = dict(discovery.upnp[ATTR_UPNP_SERVICE_LIST]) # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 1be68f91733b6a..01976c16247826 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_LOCATION, @@ -179,7 +180,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -207,7 +208,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -227,7 +228,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -244,7 +245,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -263,7 +264,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -271,7 +272,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -310,7 +311,7 @@ async def create_device_delayed(_location): # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -319,7 +320,7 @@ async def create_device_delayed(_location): ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -345,7 +346,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -360,7 +361,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -388,7 +389,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -405,7 +406,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -426,7 +427,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -447,7 +448,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -468,7 +469,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -500,7 +501,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -517,7 +518,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -538,7 +539,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -570,7 +571,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -586,7 +587,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -602,7 +603,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 7907d40c415018..5576066f781fed 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -16,6 +16,7 @@ from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import BrowseMediaSource, Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_BASE_URL, @@ -68,7 +69,7 @@ async def test_catch_request_error_unavailable( # DmsDevice notifies of disconnect via SSDP ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 3abdd2b87a3287..98b2189dfd97e0 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.doorbird.const import ( CONF_EVENTS, DEFAULT_DOORBELL_EVENT, @@ -18,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( VALID_CONFIG, @@ -74,7 +74,7 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.8"), ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", @@ -94,7 +94,7 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("169.254.103.61"), ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", @@ -121,7 +121,7 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -142,7 +142,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -164,7 +164,7 @@ async def test_form_zeroconf_correct_oui( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 2301b9dfc800e0..ccb7920e1416d0 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -45,9 +45,9 @@ async def connection_factory(*args, **kwargs): @pytest.fixture -def rfxtrx_dsmr_connection_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks RFXtrx connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -73,9 +73,9 @@ async def connection_factory(*args, **kwargs): @pytest.fixture -def dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -156,9 +156,9 @@ async def wait_closed(): @pytest.fixture -def rfxtrx_dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index 8fc996f6e34db5..9bcde251f6fdb9 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -58,7 +58,7 @@ async def test_diagnostics( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m³"}, + {"value": Decimal("745.695"), "unit": "m³"}, ], ), "GAS_METER_READING", diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 7c7d182aa97189..d590666b0607d7 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -10,6 +10,7 @@ MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -85,7 +86,7 @@ async def test_migrate_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -102,6 +103,17 @@ async def test_migrate_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -185,7 +197,7 @@ async def test_migrate_hourly_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1722749707)}, - {"value": Decimal(778.963), "unit": "m3"}, + {"value": Decimal("778.963"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -202,6 +214,17 @@ async def test_migrate_hourly_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -285,7 +308,7 @@ async def test_migrate_gas_with_devid_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -302,6 +325,18 @@ async def test_migrate_gas_with_devid_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 1 + assert device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + assert new_device.id == device.id + # Check entities are still connected to the old device + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [new_entity] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None @@ -319,6 +354,7 @@ async def test_migrate_gas_to_mbus_exists( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -380,7 +416,7 @@ async def test_migrate_gas_to_mbus_exists( telegram = Telegram() telegram.add( MBUS_DEVICE_TYPE, - CosemObject((0, 0), [{"value": "003", "unit": ""}]), + CosemObject((0, 1), [{"value": "003", "unit": ""}]), "MBUS_DEVICE_TYPE", ) telegram.add( @@ -397,7 +433,7 @@ async def test_migrate_gas_to_mbus_exists( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -414,7 +450,32 @@ async def test_migrate_gas_to_mbus_exists( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 2 + assert device_registry.async_get(device.id) + assert device_registry.async_get(device2.id) + entity = entity_registry.async_get("sensor.gas_meter_reading") + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [entity] + entity2 = entity_registry.async_get("sensor.gas_meter_reading_alt") + dev2_entities = er.async_entries_for_device( + entity_registry, device2.id, include_disabled_entities=True + ) + assert dev2_entities == [entity2] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) == "sensor.gas_meter_reading" ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading_alt" + ) + assert ( + "Skip migration of sensor.gas_meter_reading because it already exists" + in caplog.text + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 4a2951f4ed8cbc..fbe14b38aa3bba 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -89,7 +89,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.695"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -152,7 +152,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.701"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -279,7 +279,7 @@ async def test_v4_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -336,9 +336,9 @@ async def test_v4_meter( @pytest.mark.parametrize( ("value", "state"), [ - (Decimal(745.690), "745.69"), - (Decimal(745.695), "745.695"), - (Decimal(0.000), STATE_UNKNOWN), + (Decimal("745.690"), "745.69"), + (Decimal("745.695"), "745.695"), + (Decimal("0.000"), STATE_UNKNOWN), ], ) async def test_v5_meter( @@ -440,7 +440,7 @@ async def test_luxembourg_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -449,7 +449,7 @@ async def test_luxembourg_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -457,7 +457,7 @@ async def test_luxembourg_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -533,7 +533,7 @@ async def test_belgian_meter( BELGIUM_CURRENT_AVERAGE_DEMAND, CosemObject( (0, 0), - [{"value": Decimal(1.75), "unit": "kW"}], + [{"value": Decimal("1.75"), "unit": "kW"}], ), "BELGIUM_CURRENT_AVERAGE_DEMAND", ) @@ -543,7 +543,7 @@ async def test_belgian_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(4.11), "unit": "kW"}, + {"value": Decimal("4.11"), "unit": "kW"}, ], ), "BELGIUM_MAXIMUM_DEMAND_MONTH", @@ -567,7 +567,7 @@ async def test_belgian_meter( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -591,7 +591,7 @@ async def test_belgian_meter( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(678.695), "unit": "m3"}, + {"value": Decimal("678.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -615,7 +615,7 @@ async def test_belgian_meter( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -639,7 +639,7 @@ async def test_belgian_meter( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -782,7 +782,7 @@ async def test_belgian_meter_alt( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(123.456), "unit": "m3"}, + {"value": Decimal("123.456"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -806,7 +806,7 @@ async def test_belgian_meter_alt( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(678.901), "unit": "m3"}, + {"value": Decimal("678.901"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -830,7 +830,7 @@ async def test_belgian_meter_alt( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -854,7 +854,7 @@ async def test_belgian_meter_alt( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1001,7 +1001,7 @@ async def test_belgian_meter_mbus( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1017,7 +1017,7 @@ async def test_belgian_meter_mbus( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1154,7 +1154,7 @@ async def test_swedish_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1162,7 +1162,7 @@ async def test_swedish_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1229,7 +1229,7 @@ async def test_easymeter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("54184.6316"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1237,7 +1237,7 @@ async def test_easymeter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("19981.1069"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1489,7 +1489,7 @@ async def test_gas_meter_providing_energy_reading( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, + {"value": Decimal("123.456"), "unit": UnitOfEnergy.GIGA_JOULE}, ], ), "GAS_METER_READING", @@ -1549,7 +1549,7 @@ async def test_heat_meter_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "GJ"}, + {"value": Decimal("745.695"), "unit": "GJ"}, ], ), "MBUS_METER_READING", diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 2ddd8395e781a3..86805fb456f9a8 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message @pytest.mark.parametrize( @@ -93,6 +93,7 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: ) sensor = DSMRSensor(description, config_entry) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) await sensor.async_added_to_hass() # Test dsmr version, if it's a digit diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 65e0b19ea02df2..3021db62e6f50b 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -161,8 +161,8 @@ async def test_disabled_by_default_buttons( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 2185ae4c9eb17e..13b73d853d53d9 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,6 +116,6 @@ async def test_all_entities_loaded( entities: int, ) -> None: """Test that all entities are loaded together.""" - assert ( - hass.states.async_entity_ids_count() == entities - ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + assert hass.states.async_entity_ids_count() == entities, ( + f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + ) diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index a735863d40a0c1..32bc8f90696dd1 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -136,9 +136,9 @@ async def test_disabled_by_default_number_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 5bcd83853200d1..8222e9976d5f00 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -172,9 +172,9 @@ async def test_disabled_by_default_sensors( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index b14cafeaba40a0..040528debaa9ba 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -214,8 +214,8 @@ async def test_disabled_by_default_switch_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py index e75cf31eb9868d..4bfd45e92594f5 100644 --- a/tests/components/eheimdigital/test_config_flow.py +++ b/tests/components/eheimdigital/test_config_flow.py @@ -7,11 +7,11 @@ import pytest from homeassistant.components.eheimdigital.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.1"), diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 7151aab10f2470..c4234cb38aeb1d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -13,6 +13,7 @@ from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( + ATTR_MODEL, CONF_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, @@ -163,6 +164,16 @@ async def mock_config_entry_setup( @pytest.mark.parametrize( ("setup", "tts_service", "service_data"), [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), ( "mock_config_entry_setup", "speak", @@ -173,6 +184,26 @@ async def mock_config_entry_setup( tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, }, ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), ], indirect=["setup"], ) @@ -206,11 +237,13 @@ async def test_tts_service_speak( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == HTTPStatus.OK ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") tts_entity._client.generate.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice=voice_id, + model=model_id, voice_settings=tts_entity._voice_settings, optimize_streaming_latency=tts_entity._latency, ) diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 00763f60458d63..c647d36902a02a 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -6,12 +6,12 @@ from elgato import ElgatoConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -57,7 +57,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -141,7 +141,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -181,7 +181,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -202,7 +202,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index e56bb5f4699404..5355013bf94248 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -7,12 +7,12 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.elkm1.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( ELK_DISCOVERY, @@ -27,7 +27,7 @@ from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( MOCK_IP_ADDRESS, "", dr.format_mac(MOCK_MAC).replace(":", "") ) ELK_DISCOVERY_INFO = asdict(ELK_DISCOVERY) @@ -1141,7 +1141,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, macaddress="00:00:00:00:00:00", diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index 7a4d9755fa5409..be89ee4d5d6867 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -5,7 +5,6 @@ from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.elmax.const import ( CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD, @@ -23,6 +22,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_DIRECT_CERT, @@ -40,7 +40,7 @@ from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", @@ -54,7 +54,7 @@ }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST_CHANGED, ip_addresses=[MOCK_DIRECT_HOST_CHANGED], hostname="VideoBox.local", @@ -68,7 +68,7 @@ }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py index e77ebcc08b0ea3..3e5f4004d1a4cc 100644 --- a/tests/components/emonitor/test_config_flow.py +++ b/tests/components/emonitor/test_config_flow.py @@ -6,15 +6,15 @@ import aiohttp from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.emonitor.const import DOMAIN from homeassistant.const import CONF_HOST 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 -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="emonitor", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py index 287bdf6a2f4ca6..35a1346790fd87 100644 --- a/tests/components/energyzero/__init__.py +++ b/tests/components/energyzero/__init__.py @@ -1 +1,12 @@ """Tests for the EnergyZero 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 integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index d42283c0d4b685..3fd93ee31f87b6 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -29,7 +29,8 @@ def mock_config_entry() -> MockConfigEntry: title="energy", domain=DOMAIN, data={}, - unique_id="unique_thingy", + unique_id=DOMAIN, + entry_id="12345", ) diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr deleted file mode 100644 index 72e504c97c8311..00000000000000 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,39 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'energyzero', - }), - 'data': dict({ - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'energyzero', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'energyzero', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'EnergyZero', - 'unique_id': 'energyzero', - 'version': 1, - }), - 'title': 'EnergyZero', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 90c11ecfc6f557..aaa52cfeb7ef72 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics_no_gas_today dict({ 'energy': dict({ 'average_price': 0.37, @@ -16,12 +16,12 @@ 'title': 'energy', }), 'gas': dict({ - 'current_hour_price': 1.47, - 'next_hour_price': 1.47, + 'current_hour_price': None, + 'next_hour_price': None, }), }) # --- -# name: test_diagnostics_no_gas_today +# name: test_entry_diagnostics dict({ 'energy': dict({ 'average_price': 0.37, @@ -38,8 +38,8 @@ 'title': 'energy', }), 'gas': dict({ - 'current_hour_price': None, - 'next_hour_price': None, + 'current_hour_price': 1.47, + 'next_hour_price': 1.47, }), }) # --- diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 3a66f25fd32741..452f4ae748eb1d 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,20 +1,5 @@ # serializer version: 1 -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_average_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,52 +28,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', + 'unique_id': '12345_today_energy_average_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_average_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , + 'friendly_name': 'Energy market price Average - today', 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_average_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.49', + 'state': '0.37', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,51 +78,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_energy_current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'device_class': 'timestamp', - 'friendly_name': 'Energy market price Time of highest price - today', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2022-12-07T16:00:00+00:00', + 'state': '0.49', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -192,36 +127,59 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', + 'unique_id': '12345_today_energy_highest_price_time', 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-07T16:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , 'disabled_by': None, - 'entry_type': , - 'hw_version': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, 'id': , - 'is_new': False, 'labels': set({ }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hours priced equal or lower than current - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_priced_equal_or_lower', + 'unique_id': '12345_today_energy_hours_priced_equal_or_lower', + 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', @@ -236,7 +194,7 @@ 'state': '23', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -248,7 +206,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -258,43 +216,66 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Hours priced equal or lower than current - today', + 'original_name': 'Time of lowest price - today', 'platform': 'energyzero', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_priced_equal_or_lower', - 'unit_of_measurement': , + 'translation_key': 'lowest_price_time', + 'unique_id': '12345_today_energy_lowest_price_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of lowest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-07T02:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , 'disabled_by': None, - 'entry_type': , - 'hw_version': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, 'id': , - 'is_new': False, 'labels': set({ }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_price', + 'unique_id': '12345_today_energy_max_price', + 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_max_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', @@ -309,7 +290,7 @@ 'state': '0.55', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_min_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -321,7 +302,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'entity_id': 'sensor.energyzero_today_energy_min_price', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -333,57 +314,127 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Highest price - today', + 'original_name': 'Lowest price - today', 'platform': 'energyzero', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'max_price', + 'translation_key': 'min_price', + 'unique_id': '12345_today_energy_min_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 - DeviceRegistryEntrySnapshot({ +# name: test_sensor[sensor.energyzero_today_energy_min_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Lowest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.26', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + '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': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_energy_next_hour_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Next hour', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , 'disabled_by': None, - 'entry_type': , - 'hw_version': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, 'id': , - 'is_new': False, 'labels': set({ }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current percentage of highest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'percentage_of_max', + 'unique_id': '12345_today_energy_percentage_of_max', + 'unit_of_measurement': '%', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas] +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Gas market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/m³', + 'friendly_name': 'Energy market price Current percentage of highest price - today', + 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.47', + 'state': '89.09', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1 +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -414,32 +465,71 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_gas_current_hour_price', 'unit_of_measurement': '€/m³', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , 'disabled_by': None, - 'entry_type': , - 'hw_version': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, 'id': , - 'is_new': False, 'labels': set({ }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Gas market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_gas_next_hour_price', + 'unit_of_measurement': '€/m³', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Next hour', + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', }) # --- diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index 4c4e831e448023..09884ff4cf6505 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import MagicMock -from syrupy.assertion import SnapshotAssertion - from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -15,7 +13,6 @@ async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -32,7 +29,8 @@ async def test_full_user_flow( ) assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result2.get("title") == "EnergyZero" + assert result2.get("data") == {} assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index f4408ded05dae6..198f21822c7f88 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -1,53 +1,54 @@ """Tests for the diagnostics data provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.energyzero.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_diagnostics( +async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - init_integration: MockConfigEntry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == snapshot + """Test the EnergyZero entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry ) + assert result == snapshot + async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_energyzero: MagicMock, init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 5c4700c21f1bdd..d952ac775151bc 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,96 +1,49 @@ """Tests for the sensors provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props -from homeassistant.components.energyzero.const import DOMAIN -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.energyzero.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] -@pytest.mark.parametrize( - ("entity_id", "entity_unique_id", "device_identifier"), - [ - ( - "sensor.energyzero_today_energy_current_hour_price", - "today_energy_current_hour_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_average_price", - "today_energy_average_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_max_price", - "today_energy_max_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_highest_price_time", - "today_energy_highest_price_time", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_hours_priced_equal_or_lower", - "today_energy_hours_priced_equal_or_lower", - "today_energy", - ), - ( - "sensor.energyzero_today_gas_current_hour_price", - "today_gas_current_hour_price", - "today_gas", - ), - ], -) async def test_sensor( hass: HomeAssistant, - init_integration: MockConfigEntry, - device_registry: dr.DeviceRegistry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_id: str, - entity_unique_id: str, - device_identifier: str, ) -> None: """Test the EnergyZero - Energy sensors.""" - entry_id = init_integration.entry_id - assert (state := hass.states.get(entity_id)) - assert state == snapshot - assert (entity_entry := entity_registry.async_get(entity_id)) - assert entity_entry == snapshot(exclude=props("unique_id")) - assert entity_entry.unique_id == f"{entry_id}_{entity_unique_id}" + with patch("homeassistant.components.energyzero.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert device_entry == snapshot(exclude=props("identifiers")) - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_{device_identifier}")} + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("init_integration") -async def test_no_gas_today(hass: HomeAssistant, mock_energyzero: MagicMock) -> None: +async def test_no_gas_today( + hass: HomeAssistant, + mock_energyzero: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: """Test the EnergyZero - No gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) - mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("sensor.energyzero_today_gas_current_hour_price")) diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py index a53d1494e9a5d9..a16ef69979b0e2 100644 --- a/tests/components/enigma2/conftest.py +++ b/tests/components/enigma2/conftest.py @@ -1,6 +1,10 @@ """Test the Enigma2 config flow.""" -from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from openwebif.api import OpenWebIfDevice, OpenWebIfServiceEvent, OpenWebIfStatus +import pytest from homeassistant.components.enigma2.const import ( CONF_DEEP_STANDBY, @@ -10,6 +14,7 @@ DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, + DOMAIN, ) from homeassistant.const import ( CONF_HOST, @@ -20,6 +25,8 @@ CONF_VERIFY_SSL, ) +from tests.common import MockConfigEntry, load_json_object_fixture + MAC_ADDRESS = "12:34:56:78:90:ab" TEST_REQUIRED = { @@ -45,42 +52,41 @@ } -class MockDevice: - """A mock Enigma2 device.""" - - mac_address: str | None = "12:34:56:78:90:ab" - _base = "http://1.1.1.1" - - def __init__(self) -> None: - """Initialize the mock Enigma2 device.""" - self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) - - async def _call_api(self, url: str) -> dict | None: - if url.endswith("/api/about"): - return { - "info": { - "ifaces": [ - { - "mac": self.mac_address, - } - ], - "model": "Mock Enigma2", - "brand": "Enigma2", - } - } - return None - - def get_version(self) -> str | None: - """Return the version.""" - return None - - async def get_about(self) -> dict: - """Get mock about endpoint.""" - return await self._call_api("/api/about") - - async def get_all_bouquets(self) -> dict: - """Get all bouquets.""" - return { +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, unique_id="12:34:56:78:90:ab" + ) + + +@pytest.fixture +def openwebif_device_mock() -> Generator[AsyncMock]: + """Mock a OpenWebIf device.""" + + with ( + patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice", + spec=OpenWebIfDevice, + ) as openwebif_device_mock, + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice", + new=openwebif_device_mock, + ), + ): + device = openwebif_device_mock.return_value + device.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) + device.turn_off_to_deep = False + device.sources = {"Test": "1"} + device.source_list = list(device.sources.keys()) + device.picon_url = "file:///" + device.get_about.return_value = load_json_object_fixture( + "device_about.json", DOMAIN + ) + device.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_on.json", DOMAIN + ) + device.get_all_bouquets.return_value = { "bouquets": [ [ '1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.favourites.tv" ORDER BY bouquet', @@ -88,9 +94,4 @@ async def get_all_bouquets(self) -> dict: ] ] } - - async def update(self) -> None: - """Mock update.""" - - async def close(self): - """Mock close.""" + yield device diff --git a/tests/components/enigma2/fixtures/device_about.json b/tests/components/enigma2/fixtures/device_about.json new file mode 100644 index 00000000000000..5b992fa1bd544e --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": "12:34:56:78:90:ab", + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_about_without_mac.json b/tests/components/enigma2/fixtures/device_about_without_mac.json new file mode 100644 index 00000000000000..02a84edcac253b --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about_without_mac.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": null, + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_on.json b/tests/components/enigma2/fixtures/device_statusinfo_on.json new file mode 100644 index 00000000000000..0c8701c7b74214 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_on.json @@ -0,0 +1,20 @@ +{ + "volume": 100, + "muted": false, + "transcoding": true, + "currservice_filename": "", + "currservice_id": 38835, + "currservice_name": "Flucht aus Saudi-Arabien", + "currservice_serviceref": "1:0:19:2BA2:3F2:1:C00000:0:0:0:", + "currservice_begin": "16:30", + "currservice_begin_timestamp": 1734622200, + "currservice_end": "17:15", + "currservice_end_timestamp": 1734624900, + "currservice_description": "Ein M\u00e4dchen k\u00e4mpft um die Freiheit", + "currservice_station": "ZDFinfo HD", + "currservice_fulldescription": "Flucht aus Saudi-Arabien\n16:30 - 17:15\n\nSaudi-Arabien / Australien 2019\nIm streng islamischen K\u00f6nigreich Saudi-Arabien haben Frauen immer noch wenig Rechte. Wenn sie ein selbstbestimmtes Leben f\u00fchren wollen, bleibt ihnen h\u00e4ufig nur die Flucht.\n\nDie 18-j\u00e4hrige Rahaf Mohammed al-Qunun ist eine solche junge Frau, die riskiert hat, dem m\u00e4nnlich gepr\u00e4gten Vormundschaftssystem zu entfliehen. Doch der saudische Staat und Rahafs Familie verfolgen die Abtr\u00fcnnige sogar bis ins Ausland.\nHD-Produktion", + "inStandby": "false", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_standby.json b/tests/components/enigma2/fixtures/device_statusinfo_standby.json new file mode 100644 index 00000000000000..18cc19f5901dba --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_standby.json @@ -0,0 +1,16 @@ +{ + "volume": 100, + "muted": true, + "transcoding": true, + "currservice_filename": "", + "currservice_id": -1, + "currservice_name": "N/A", + "currservice_begin": "", + "currservice_end": "", + "currservice_description": "", + "currservice_fulldescription": "N/A", + "inStandby": "true", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index 8d32da42bafc9c..1445048f0c1a84 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -1,19 +1,19 @@ """Test the Enigma2 config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientError from openwebif.error import InvalidAuthError import pytest -from homeassistant import config_entries from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_FULL, TEST_REQUIRED, MockDevice +from .conftest import TEST_FULL, TEST_REQUIRED from tests.common import MockConfigEntry @@ -22,42 +22,35 @@ async def user_flow(hass: HomeAssistant) -> str: """Return a user-initiated flow after filling in host info.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None return result["flow_id"] +@pytest.mark.usefixtures("openwebif_device_mock") @pytest.mark.parametrize( ("test_config"), [(TEST_FULL), (TEST_REQUIRED)], ) -async def test_form_user( - hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] -) -> None: +async def test_form_user(hass: HomeAssistant, test_config: dict[str, Any]) -> None: """Test a successful user initiated flow.""" - with ( - patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(user_flow, test_config) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.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" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], test_config + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == test_config[CONF_HOST] assert result["data"] == test_config - assert len(mock_setup_entry.mock_calls) == 1 - @pytest.mark.parametrize( - ("exception", "error_type"), + ("side_effect", "error_value"), [ (InvalidAuthError, "invalid_auth"), (ClientError, "cannot_connect"), @@ -65,46 +58,87 @@ async def test_form_user( ], ) async def test_form_user_errors( - hass: HomeAssistant, user_flow, exception: Exception, error_type: str + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + side_effect: Exception, + error_value: str, ) -> None: """Test we handle errors.""" - with patch( - "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + openwebif_device_mock.get_about.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_FULL + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {"base": error_type} + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {"base": error_value} + openwebif_device_mock.get_about.side_effect = None -async def test_options_flow(hass: HomeAssistant, user_flow: str) -> None: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_FULL, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_FULL[CONF_HOST] + assert result["data"] == TEST_FULL + assert result["result"].unique_id == openwebif_device_mock.mac_address + + +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_duplicate_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a duplicate host aborts the config flow.""" + mock_config_entry.add_to_hass(hass) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_FULL + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_options_flow(hass: HomeAssistant) -> None: """Test the form options.""" - with patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert entry.options == {"source_bouquet": "Favourites (TV)"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {"source_bouquet": "Favourites (TV)"} - await hass.async_block_till_done() + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py index d12f96d4b0f0d1..a3f68cd09029c2 100644 --- a/tests/components/enigma2/test_init.py +++ b/tests/components/enigma2/test_init.py @@ -1,46 +1,45 @@ """Test the Enigma2 integration init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +import pytest from homeassistant.components.enigma2.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import TEST_REQUIRED, MockDevice +from .conftest import TEST_REQUIRED -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_device_without_mac_address( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + device_registry: dr.DeviceRegistry, ) -> None: """Test that a device gets successfully registered when the device doesn't report a MAC address.""" - mock_device = MockDevice() - mock_device.mac_address = None - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=mock_device, - ): - entry = MockConfigEntry( - domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None + openwebif_device_mock.get_about.return_value = load_json_object_fixture( + "device_about_without_mac.json", DOMAIN + ) + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id == "123456" + assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None +@pytest.mark.usefixtures("openwebif_device_mock") async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_media_player.py b/tests/components/enigma2/test_media_player.py new file mode 100644 index 00000000000000..dd1dcb66cb6e31 --- /dev/null +++ b/tests/components/enigma2/test_media_player.py @@ -0,0 +1,305 @@ +"""Tests for the media player module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption +import pytest + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.components.enigma2.media_player import ATTR_MEDIA_CURRENTLY_RECORDING +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, +) +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) + + +@pytest.mark.parametrize( + ("deep_standby", "powerstate"), + [(False, PowerState.STANDBY), (True, PowerState.DEEP_STANDBY)], +) +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + deep_standby: bool, + powerstate: PowerState, +) -> None: + """Test turning off the media player.""" + openwebif_device_mock.turn_off_to_deep = deep_standby + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_powerstate.assert_awaited_once_with(powerstate) + + +async def test_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test turning on the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.turn_on.assert_awaited_once() + + +async def test_set_volume_level( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test setting the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_LEVEL: 0.2}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(20) + + +async def test_volume_up( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test increasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.UP) + + +async def test_volume_down( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test decreasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.DOWN) + + +@pytest.mark.parametrize( + ("service", "remote_code"), + [ + (SERVICE_MEDIA_STOP, RemoteControlCodes.STOP), + (SERVICE_MEDIA_PLAY, RemoteControlCodes.PLAY), + (SERVICE_MEDIA_PAUSE, RemoteControlCodes.PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, RemoteControlCodes.CHANNEL_UP), + (SERVICE_MEDIA_PREVIOUS_TRACK, RemoteControlCodes.CHANNEL_DOWN), + ], +) +async def test_remote_control_actions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + service: str, + remote_code: RemoteControlCodes, +) -> None: + """Test media stop.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.send_remote_control_action.assert_awaited_once_with( + remote_code + ) + + +@pytest.mark.parametrize("mute", [False, True]) +async def test_volume_mute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + mute: bool, +) -> None: + """Test mute.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_MUTED: mute}, + ) + + openwebif_device_mock.toggle_mute.assert_awaited_once() + + +async def test_select_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test media previous track.""" + openwebif_device_mock.return_value.sources = {"Test": "1"} + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_INPUT_SOURCE: "Test"}, + ) + + openwebif_device_mock.zap.assert_awaited_once_with("1") + + +async def test_update_data_standby( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test data handling.""" + + openwebif_device_mock.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_standby.json", DOMAIN + ) + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=True + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_CURRENTLY_RECORDING + not in hass.states.get("media_player.1_1_1_1").attributes + ) + assert hass.states.get("media_player.1_1_1_1").state == MediaPlayerState.OFF + + +async def test_update_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=100 + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.1_1_1_1").attributes[ATTR_MEDIA_VOLUME_LEVEL] + > 0.99 + ) + + +async def test_update_volume_none( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=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() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_VOLUME_LEVEL + not in hass.states.get("media_player.1_1_1_1").attributes + ) diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 121c2583050b42..a3da14b3835088 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -7,7 +7,6 @@ from pyenphase import EnvoyAuthenticationError, EnvoyError import pytest -from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_integration @@ -163,7 +163,7 @@ async def test_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -273,7 +273,7 @@ async def test_zeroconf_serial_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -301,7 +301,7 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -330,7 +330,7 @@ async def test_zeroconf_host_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -363,7 +363,7 @@ async def test_zero_conf_while_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -396,7 +396,7 @@ async def test_zero_conf_second_envoy_while_form( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -434,119 +434,6 @@ async def test_zero_conf_second_envoy_while_form( assert result4["type"] is FlowResultType.ABORT -async def test_zero_conf_malformed_serial_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - with pytest.raises(KeyError) as ex: - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serilnum": "1234", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert "serialnum" in str(ex.value) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.ABORT - - -async def test_zero_conf_malformed_serial( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "12%4", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Envoy 12%4" - - -async def test_zero_conf_malformed_fw_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf property.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234", "protvers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "1.1.1.1" - assert config_entry.unique_id == "1234" - assert config_entry.title == "Envoy 1234" - - async def test_zero_conf_old_blank_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -568,7 +455,7 @@ async def test_zero_conf_old_blank_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -609,7 +496,7 @@ async def test_zero_conf_old_blank_entry_standard_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -650,7 +537,7 @@ async def test_zero_conf_old_blank_entry_user_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 2b35aaff5e913d..10cf65a298da5d 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -263,7 +263,7 @@ async def test_config_different_unique_id( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", - unique_id=4321, + unique_id="4321", data={ CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy 1234", @@ -346,8 +346,10 @@ async def test_option_change_reload( await setup_integration(hass, config_entry) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED + # By default neither option is available + assert config_entry.options == {} - # option change will take care of COV of init::async_reload_entry + # option change will also take care of COV of init::async_reload_entry hass.config_entries.async_update_entry( config_entry, options={ @@ -355,8 +357,23 @@ async def test_option_change_reload( OPTION_DISABLE_KEEP_ALIVE: True, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, OPTION_DISABLE_KEEP_ALIVE: True, } + # flip em + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == { + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + } diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 46858c5826b61a..31d9fcd34f9999 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -1,5 +1,7 @@ """Test the ESPHome bluetooth integration.""" +from unittest.mock import patch + from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant @@ -44,3 +46,22 @@ async def test_bluetooth_connect_with_legacy_adv( await hass.async_block_till_done() scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner.scanning is True + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.connectable is True + await hass.config_entries.async_unload( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + + with patch("homeassistant.components.esphome.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, scanner.source) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0a389969c78ba1..65dab4c516ffe2 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -18,7 +18,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, @@ -30,8 +29,10 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK @@ -126,7 +127,7 @@ async def test_user_sets_unique_id( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -205,7 +206,7 @@ async def test_user_causes_zeroconf_to_abort( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -568,7 +569,7 @@ async def test_discovery_initiation( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery importing works.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -601,7 +602,7 @@ async def test_discovery_no_mac( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -629,7 +630,7 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -650,7 +651,7 @@ async def test_discovery_duplicate_data( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test discovery aborts if same mDNS packet arrives.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -685,7 +686,7 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1056,7 +1057,7 @@ async def test_discovery_dhcp_updates_host( ) entry.add_to_hass(hass) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test8266", macaddress="1122334455aa", @@ -1083,7 +1084,7 @@ async def test_discovery_dhcp_no_changes( mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.183", hostname="test8266", macaddress="000000000000", @@ -1132,7 +1133,7 @@ async def test_zeroconf_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1198,7 +1199,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1264,7 +1265,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key not retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 4b322c8744e12c..6fbd3726f64dc4 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -21,7 +21,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, @@ -37,6 +36,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -598,7 +598,7 @@ async def async_disconnect(*args, **kwargs) -> None: mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index d1cb892d54894c..c8bd0bb192ce87 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -24,12 +24,12 @@ ) from flux_led.scanner import FluxLEDDiscovery -from homeassistant.components import dhcp from homeassistant.components.flux_led.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -49,7 +49,7 @@ DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=MODEL, ip=IP_ADDRESS, macaddress=format_mac(MAC_ADDRESS).replace(":", ""), diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index 4332cb69f02500..f486d27244e500 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.flux_led.config_flow import FluxLedConfigFlow from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, @@ -27,6 +26,7 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_TITLE, @@ -424,7 +424,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 076fffef59b748..8bf5de31da2662 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -5,7 +5,6 @@ import pytest -from homeassistant.components import zeroconf from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, @@ -19,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_zeroconf_updates_title( MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "different host"}).add_to_hass(hass) config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", @@ -146,7 +146,7 @@ async def test_config_flow_no_websocket( async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -161,7 +161,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -176,7 +176,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -191,7 +191,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -209,7 +209,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 2fe4e1b77de422..e6adae572f3016 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,7 +1,7 @@ """Test helpers for Freebox.""" import json -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, patch from freebox_api.exceptions import HttpRequestError import pytest @@ -36,16 +36,6 @@ def mock_path(): yield -@pytest.fixture(autouse=True) -def enable_all_entities(): - """Make sure all entities are enabled.""" - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ): - yield - - @pytest.fixture def mock_device_registry_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index 4950ef27e5f983..a56f3ed0789034 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, @@ -45,6 +46,7 @@ async def test_raid_array_degraded( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_home( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index ca9e9c1293795a..50dd2f8c14eee4 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -9,18 +9,18 @@ InvalidTokenError, ) -from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.254"), ip_addresses=[ip_address("192.168.0.254")], port=80, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index acd96879b1e3b3..1e292ed22bba4c 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -1,8 +1,6 @@ """Common stuff for Fritz!Tools tests.""" -from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -11,6 +9,11 @@ CONF_SSL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) ATTR_HOST = "host" ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" @@ -941,7 +944,7 @@ ATTR_HOST: MOCK_HOST, ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, } -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://{MOCK_IPS['fritz.box']}:12345/test", diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 84f1b240b88f4c..f4c4229af7489c 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,7 +10,6 @@ ) import pytest -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -33,6 +32,11 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( MOCK_FIRMWARE_INFO, @@ -644,7 +648,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -745,13 +749,13 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", }, ), ) diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0df6d0b2ea9406..0c8a7996898c5c 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -9,13 +9,16 @@ import pytest from requests.exceptions import HTTPError -from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -23,7 +26,7 @@ MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { - "ip4_valid": ssdp.SsdpServiceInfo( + "ip4_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://10.0.0.1:12345/test", @@ -32,7 +35,7 @@ ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_valid": ssdp.SsdpServiceInfo( + "ip6_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[1234::1]:12345/test", @@ -41,7 +44,7 @@ ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_invalid": ssdp.SsdpServiceInfo( + "ip6_invalid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1%1]:12345/test", @@ -264,7 +267,7 @@ async def test_reconfigure_failed(hass: HomeAssistant, fritz: Mock) -> None: async def test_ssdp( hass: HomeAssistant, fritz: Mock, - test_data: ssdp.SsdpServiceInfo, + test_data: SsdpServiceInfo, expected_result: str, ) -> None: """Test starting a flow from discovery.""" diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 933b8fad8ef39d..819f960c64b786 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -6,11 +6,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fronius.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import mock_responses diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index c92cf897fe618c..f60e9ad557eda2 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.frontier_silicon.const import ( CONF_WEBFSAPI_URL, DEFAULT_PIN, @@ -15,13 +14,14 @@ from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", @@ -30,7 +30,7 @@ upnp={"SPEAKER-NAME": "Speaker Name"}, ) -INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +INVALID_MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 5974adbeb0db84..71a11557b44252 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, create_autospec, patch from ayla_iot_unofficial import AylaApi @@ -12,7 +12,8 @@ DOMAIN, REGION_DEFAULT, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,6 +34,12 @@ } +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -78,6 +85,24 @@ def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: ) +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + platforms: list[Platform], + mock_config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch("homeassistant.components.fujitsu_fglair.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + def _create_device(serial_number: str) -> AsyncMock: dev = AsyncMock(spec=FujitsuHVAC) dev.device_serial_number = serial_number @@ -109,6 +134,7 @@ def _create_device(serial_number: str) -> AsyncMock: dev.temperature_range = [18.0, 26.0] dev.sensed_temp = 22.0 dev.set_temp = 21.0 + dev.outdoor_temperature = 5.0 return dev diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..89738cc4a66d6f --- /dev/null +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[sensor.testserial123_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial123_outside_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': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial123_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial123_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial123 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial123_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial345_outside_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': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial345_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial345 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial345_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index daddc83a871963..676ff97f26a3b3 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -1,7 +1,9 @@ """Test for the climate entities of Fujitsu HVAC.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -23,24 +25,32 @@ HA_TO_FUJI_HVAC, HA_TO_FUJI_SWING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import entity_id, setup_integration +from . import entity_id from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_ayla_api: AsyncMock, mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that coordinator returns the data we expect after the first refresh.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -51,9 +61,10 @@ async def test_set_attributes( mock_ayla_api: AsyncMock, mock_devices: list[AsyncMock], mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that setting the attributes calls the correct functions on the device.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index af51b222c191ad..a69610c416db09 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.climate import HVACMode from homeassistant.components.fujitsu_fglair.const import ( API_REFRESH, API_TIMEOUT, @@ -17,14 +18,9 @@ REGION_EU, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_UNAVAILABLE, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers import aiohttp_client from . import entity_id, setup_integration from .conftest import TEST_PASSWORD, TEST_USERNAME @@ -129,6 +125,26 @@ async def test_device_auth_failure( assert hass.states.get(entity_id(mock_devices[1])).state == STATE_UNAVAILABLE +async def test_device_offline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: list[AsyncMock], +) -> None: + """Test entities become unavailable if device if offline.""" + await setup_integration(hass, mock_config_entry) + + mock_ayla_api.async_get_devices.return_value[0].is_online.return_value = False + + freezer.tick(API_REFRESH) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id(mock_devices[0])).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id(mock_devices[1])).state == HVACMode.COOL + + async def test_token_expired( hass: HomeAssistant, mock_ayla_api: AsyncMock, @@ -166,36 +182,3 @@ async def test_startup_exception( await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - - -async def test_one_device_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, - mock_devices: list[AsyncMock], - mock_ayla_api: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that coordinator only updates devices that are currently listening.""" - await setup_integration(hass, mock_config_entry) - - for d in mock_devices: - d.async_update.assert_called_once() - d.reset_mock() - - entity = entity_registry.async_get( - entity_registry.async_get_entity_id( - Platform.CLIMATE, DOMAIN, mock_devices[0].device_serial_number - ) - ) - entity_registry.async_update_entity( - entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - await hass.async_block_till_done() - freezer.tick(API_REFRESH) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == len(mock_devices) - 1 - mock_devices[0].async_update.assert_not_called() - mock_devices[1].async_update.assert_called_once() diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py new file mode 100644 index 00000000000000..e3f6109a2e87b1 --- /dev/null +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -0,0 +1,33 @@ +"""Test for the sensor platform entity of the fujitsu_fglair component.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 873fb2c679677d..4ce393a417d6e8 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -6,7 +6,6 @@ from fullykiosk import FullyKioskError import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER from homeassistant.const import ( @@ -18,6 +17,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 600fc46608cd6b..ca5662714a00f2 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -1,6 +1,9 @@ { "battery_level": 80, - "low_battery": true, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, "light_status": 3, @@ -10,7 +13,7 @@ "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", - "sensor_update_available": false, + "sensor_update_available": true, "sw_version": "1.0", "status": 1, "online": true, diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index c39e2ac8685a8f..bf90ab1e50d71d 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 58e3e1b86a0b49..2bedd196fe1c2e 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": false + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..c90db22bc7ff7a --- /dev/null +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -0,0 +1,741 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.gummibaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Gummibaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + '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': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + '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': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + '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': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_repotted', + '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': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Gummibaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + '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 notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kakaobaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + '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': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + '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': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + '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': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_repotted', + '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': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Kakaobaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + '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 notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index eb19797e5b1a79..b4da0238db09f3 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, 'light_status': 3, - 'low_battery': True, + 'low_battery': False, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Gummibaum', @@ -46,14 +46,14 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, 'sensor_id': 'FD:1D:B7:E3:D0:E2', 'sensor_status': 0, - 'sensor_update_available': False, + 'sensor_update_available': True, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, @@ -81,7 +81,7 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index ef583dd28a6e7f..8b75579f55701b 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -50,6 +50,53 @@ 'state': '80.0', }) # --- +# name: test_all_entities[sensor.gummibaum_last_fertilized-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_last_fertilized', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last fertilized', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_fertilised', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_last', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_last_fertilized-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Gummibaum Last fertilized', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_last_fertilized', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[sensor.gummibaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -279,6 +326,117 @@ 'state': 'perfect', }) # --- +# name: test_all_entities[sensor.gummibaum_next_fertilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_next_fertilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next fertilization', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_fertilisation', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_next', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_next_fertilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Gummibaum Next fertilization', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_next_fertilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.gummibaum_nutrients_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_nutrients_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nutrients state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nutrients_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-nutrients_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_nutrients_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gummibaum Nutrients state', + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'context': , + 'entity_id': 'sensor.gummibaum_nutrients_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'perfect', + }) +# --- # name: test_all_entities[sensor.gummibaum_ph-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -716,6 +874,53 @@ 'state': '80.0', }) # --- +# name: test_all_entities[sensor.kakaobaum_last_fertilized-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_last_fertilized', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last fertilized', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_fertilised', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_last', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_last_fertilized-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Kakaobaum Last fertilized', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_last_fertilized', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[sensor.kakaobaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -945,6 +1150,117 @@ 'state': 'perfect', }) # --- +# name: test_all_entities[sensor.kakaobaum_next_fertilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_next_fertilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next fertilization', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_fertilisation', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_next', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_next_fertilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Kakaobaum Next fertilization', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_next_fertilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.kakaobaum_nutrients_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_nutrients_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nutrients state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nutrients_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-nutrients_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_nutrients_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Kakaobaum Nutrients state', + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_nutrients_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'perfect', + }) +# --- # name: test_all_entities[sensor.kakaobaum_ph-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py new file mode 100644 index 00000000000000..9d6a4ae3b0e7a9 --- /dev/null +++ b/tests/components/fyta/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test the Home Assistant fyta binary sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_UNAVAILABLE + ) + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_ON + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.kakaobaum_repotted") is None + assert hass.states.get("binary_sensor.tomatenpflanze_repotted").state == STATE_OFF diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 21101db8534219..d1e6e326737b44 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -10,11 +10,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index cdea83b599c7fb..96cdfe41d0d298 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from io import BytesIO -from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from PIL import Image import pytest @@ -54,11 +54,15 @@ def fakeimgbytes_gif() -> bytes: @pytest.fixture def fakeimg_png(fakeimgbytes_png: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + respx.get("http://127.0.0.1/testurl/1", name="fake_img1").respond( + stream=fakeimgbytes_png + ) + respx.get("http://127.0.0.1/testurl/2", name="fake_img2").respond( stream=fakeimgbytes_png ) yield - respx.pop("fake_img") + respx.pop("fake_img1") + respx.pop("fake_img2") @pytest.fixture @@ -71,8 +75,8 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: respx.pop("fake_img") -@pytest.fixture -def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: +@pytest.fixture(name="mock_create_stream") +def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock create stream.""" mock_stream = MagicMock() mock_stream.hass = hass @@ -83,14 +87,25 @@ def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: mock_stream.start = AsyncMock() mock_stream.stop = AsyncMock() mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing" - return patch( + with patch( "homeassistant.components.generic.config_flow.create_stream", return_value=mock_stream, - ) + ) as mock_create_stream: + yield mock_create_stream @pytest.fixture -async def user_flow(hass: HomeAssistant) -> ConfigFlowResult: +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.generic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="user_flow") +async def user_flow_fixture(hass: HomeAssistant) -> ConfigFlowResult: """Initiate a user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -126,8 +141,8 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: return entry -@pytest.fixture -async def setup_entry( +@pytest.fixture(name="setup_entry") +async def setup_entry_fixture( hass: HomeAssistant, config_entry: MockConfigEntry ) -> MockConfigEntry: """Set up a config entry ready to be used in tests.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 9eee49619b5032..19af6cd7a0921b 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator import contextlib import errno from http import HTTPStatus @@ -9,12 +10,10 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch -from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx -from homeassistant import config_entries from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( @@ -30,7 +29,7 @@ CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) -from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -44,7 +43,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { @@ -69,52 +68,47 @@ @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form( hass: HomeAssistant, fakeimgbytes_png: bytes, hass_client: ClientSessionGenerator, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], hass_ws_client: WebSocketGenerator, ) -> None: """Test the form with a normal set of settings.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with ( - mock_create_stream as mock_setup, - patch( - "homeassistant.components.generic.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" - # HA should now be serving a WS connection for a preview stream. - ws_client = await hass_ws_client() - flow_id = user_flow["flow_id"] - await ws_client.send_json_auto_id( - { - "type": "generic_camera/start_preview", - "flow_id": flow_id, - }, - ) - json = await ws_client.receive_json() + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() - client = await hass_client() - still_preview_url = json["event"]["attributes"]["still_url"] - # Check the preview image works. - resp = await client.get(still_preview_url) - assert resp.status == HTTPStatus.OK - assert await resp.read() == fakeimgbytes_png + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -131,36 +125,29 @@ async def test_form( # Check that the preview image is disabled after. resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.NOT_FOUND - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_only_stillimage( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants still images only.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -177,19 +164,17 @@ async def test_form_only_stillimage( @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_reject_preview( hass: HomeAssistant, - fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, ) -> None: """Test we go back to the config screen if the user rejects the preview.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( @@ -215,7 +200,6 @@ async def test_form_still_preview_cam_off( "homeassistant.components.generic.camera.GenericCamera.is_on", new_callable=PropertyMock(return_value=False), ), - mock_create_stream, ): result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], @@ -246,47 +230,50 @@ async def test_form_still_preview_cam_off( @respx.mock @pytest.mark.usefixtures("fakeimg_gif") async def test_form_only_stillimage_gif( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @respx.mock async def test_form_only_svg_whitespace( - hass: HomeAssistant, fakeimgbytes_svg: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, + fakeimgbytes_svg: bytes, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if svg starts with whitespace, issue #68889.""" fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -303,7 +290,7 @@ async def test_form_only_svg_whitespace( ], ) async def test_form_only_still_sample( - hass: HomeAssistant, user_flow: ConfigFlowResult, image_file + hass: HomeAssistant, user_flow: ConfigFlowResult, image_file, mock_setup_entry ) -> None: """Test various sample images #69037.""" image_path = os.path.join(os.path.dirname(__file__), image_file) @@ -311,18 +298,17 @@ async def test_form_only_still_sample( respx.get("http://127.0.0.1/testurl/1").respond(stream=image_bytes) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -363,10 +349,11 @@ async def test_form_only_still_sample( ), ], ) -async def test_still_template( +async def test_form_still_template( hass: HomeAssistant, user_flow: ConfigFlowResult, fakeimgbytes_png: bytes, + mock_setup_entry: Generator[AsyncMock], template, url, expected_result, @@ -380,12 +367,11 @@ async def test_still_template( data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -396,24 +382,19 @@ async def test_form_rtsp_mode( hass: HomeAssistant, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + 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[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" - with ( - mock_create_stream as mock_setup, - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], data - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -428,8 +409,6 @@ async def test_form_rtsp_mode( CONF_VERIFY_SSL: False, } - assert len(mock_setup.mock_calls) == 1 - async def test_form_only_stream( hass: HomeAssistant, @@ -441,18 +420,16 @@ async def test_form_only_stream( data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) assert result1["type"] is FlowResultType.FORM - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -528,15 +505,11 @@ async def test_form_image_http_exceptions( mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle image http exceptions.""" - respx.get("http://127.0.0.1/testurl/1").side_effect = [ - side_effect, - ] - - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + respx.get("http://127.0.0.1/testurl/1").side_effect = [side_effect] + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == expected_message @@ -550,11 +523,10 @@ async def test_form_stream_invalidimage( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -568,11 +540,10 @@ async def test_form_stream_invalidimage2( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @@ -586,11 +557,10 @@ async def test_form_stream_invalidimage3( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -599,23 +569,22 @@ async def test_form_stream_invalidimage3( @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_timeout( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream" - ) as create_stream: - create_stream.return_value.start = AsyncMock() - create_stream.return_value.stop = AsyncMock() - create_stream.return_value.hass = hass - create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() - create_stream.return_value.add_provider.return_value.part_recv.return_value = ( - False - ) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + mock_create_stream.return_value.start = AsyncMock() + mock_create_stream.return_value.stop = AsyncMock() + mock_create_stream.return_value.hass = hass + mock_create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + mock_create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"stream_source": "timeout"} @@ -661,11 +630,11 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_permission_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: """Test we handle permission error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), @@ -732,116 +701,73 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) -> @respx.mock -async def test_form_stream_preview_auto_timeout( +@pytest.mark.usefixtures("fakeimg_png") +async def test_options_template_error( hass: HomeAssistant, - user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], - freezer: FrozenDateTimeFactory, - fakeimgbytes_png: bytes, -) -> None: - """Test that the stream preview times out after 10mins.""" - respx.get("http://fred_flintstone:bambam@127.0.0.1/testurl/2").respond( - stream=fakeimgbytes_png - ) - data = TESTDATA.copy() - data.pop(CONF_STILL_IMAGE_URL) - - with mock_create_stream as mock_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - - freezer.tick(600 + 12) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - mock_str = mock_stream.return_value - mock_str.start.assert_awaited_once() - - -@respx.mock -async def test_options_template_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + config_entry: MockConfigEntry, ) -> None: """Test the options flow with a template error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # try updating the still image url data = TESTDATA.copy() data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user_confirm" + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_confirm" - result2a = await hass.config_entries.options.async_configure( - result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} - ) - assert result2a["type"] is FlowResultType.CREATE_ENTRY + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] is FlowResultType.CREATE_ENTRY - result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "init" + result3 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "init" - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input=data, - ) - assert result4.get("type") is FlowResultType.FORM - assert result4["errors"] == {"still_image_url": "template_error"} - - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" - result5 = await hass.config_entries.options.async_configure( - result4["flow_id"], - user_input=data, - ) + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input=data, + ) + assert result4.get("type") is FlowResultType.FORM + assert result4["errors"] == {"still_image_url": "template_error"} - assert result5.get("type") is FlowResultType.FORM - assert result5["errors"] == {"stream_source": "template_error"} + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input=data, + ) - # verify that an relative stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" - result6 = await hass.config_entries.options.async_configure( - result5["flow_id"], - user_input=data, - ) - assert result6.get("type") is FlowResultType.FORM - assert result6["errors"] == {"stream_source": "relative_url"} - - # verify that an malformed stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://example.com:45:56" - result7 = await hass.config_entries.options.async_configure( - result6["flow_id"], - user_input=data, - ) + assert result5.get("type") is FlowResultType.FORM + assert result5["errors"] == {"stream_source": "template_error"} + + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") is FlowResultType.FORM + assert result6["errors"] == {"stream_source": "relative_url"} + + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) assert result7.get("type") is FlowResultType.FORM assert result7["errors"] == {"stream_source": "malformed_url"} @@ -861,11 +787,13 @@ async def test_slug(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_only_stream( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], + mock_create_stream: _patch[MagicMock], ) -> None: """Test the options flow without a still_image_url.""" - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) @@ -883,11 +811,10 @@ async def test_options_only_stream( assert result["step_id"] == "init" # try updating the config options - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user_confirm" @@ -900,6 +827,7 @@ async def test_options_only_stream( async def test_options_still_and_stream_not_provided( hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we show a suitable error if neither still or stream URL are provided.""" data = TESTDATA.copy() @@ -929,7 +857,7 @@ async def test_options_still_and_stream_not_provided( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_form_options_permission_error( +async def test_options_permission_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test we handle a PermissionError and pass the message through.""" @@ -947,43 +875,6 @@ async def test_form_options_permission_error( assert result2["errors"] == {"stream_source": "stream_not_permitted"} -@pytest.mark.usefixtures("fakeimg_png") -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test unloading the generic IP Camera entry.""" - mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_reload_on_title_change(hass: HomeAssistant) -> None: - """Test the integration gets reloaded when the title is updated.""" - - test_data = TESTDATA_OPTIONS - test_data[CONF_CONTENT_TYPE] = "image/png" - mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id="54321", options=test_data, title="My Title" - ) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "My Title" - - hass.config_entries.async_update_entry(mock_entry, title="New Title") - assert mock_entry.title == "New Title" - await hass.async_block_till_done() - - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" - - async def test_migrate_existing_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -1019,40 +910,26 @@ async def test_migrate_existing_ids( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_use_wallclock_as_timestamps_option( +async def test_options_use_wallclock_as_timestamps( hass: HomeAssistant, mock_create_stream: _patch[MagicMock], hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, fakeimgbytes_png: bytes, + config_entry: MockConfigEntry, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test the use_wallclock_as_timestamps option flow.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} + config_entry.entry_id, context={"show_advanced_options": True} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result2["type"] is FlowResultType.FORM ws_client = await hass_ws_client() @@ -1079,14 +956,10 @@ async def test_use_wallclock_as_timestamps_option( ) assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result4["type"] is FlowResultType.FORM assert result4["step_id"] == "user_confirm" result5 = await hass.config_entries.options.async_configure( diff --git a/tests/components/generic/test_init.py b/tests/components/generic/test_init.py new file mode 100644 index 00000000000000..faa00ee9144176 --- /dev/null +++ b/tests/components/generic/test_init.py @@ -0,0 +1,37 @@ +"""Define tests for the generic (IP camera) integration.""" + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("fakeimg_png") +async def test_unload_entry(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: + """Test unloading the generic IP Camera entry.""" + assert setup_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert setup_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_on_title_change( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test the integration gets reloaded when the title is updated.""" + assert setup_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] + == "Test Camera" + ) + + hass.config_entries.async_update_entry(setup_entry, title="New Title") + assert setup_entry.title == "New Title" + await hass.async_block_till_done() + + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title" + ) diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index 30a7c92510e18b..7d86f638fc2fd9 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components import dhcp from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,7 +19,7 @@ CONF_NAME: DEFAULT_NAME, } -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=format_mac("AA:BB:CC:DD:EE:FF").replace(":", ""), hostname="yeti", diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 25fb5922506b67..1e7e48437cd1bf 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -8,7 +8,6 @@ from ismartgate.const import GogoGate2ApiErrorCode from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, @@ -23,6 +22,11 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import _mocked_ismartgate_closed_door_response @@ -105,13 +109,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -133,13 +137,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -158,13 +162,13 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -177,13 +181,13 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -213,7 +217,7 @@ async def test_discovered_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -260,13 +264,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -276,7 +280,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -286,7 +290,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" ), ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 6ce95a2bc170d4..305f30d99d40a8 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -751,6 +751,7 @@ async def test_unique_id_migration( old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -805,6 +806,7 @@ async def test_invalid_unique_id_cleanup( mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 0e6876cc901992..a5451e5332d50f 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -316,7 +316,7 @@ async def test_sync_notifications(agents) -> None: config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT ) as mock: await config.async_sync_notification_all("1234", {}) - assert not agents or bool(mock.mock_calls) and agents + assert not agents or (bool(mock.mock_calls) and agents) @pytest.mark.parametrize( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index a1c2ba1b3d4020..3b43728988b985 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -51,7 +51,7 @@ from . import BASIC_CONFIG, MockConfig -from tests.common import MockConfigEntry, async_capture_events +from tests.common import MockConfigEntry, MockEntityPlatform, async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -156,6 +156,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -301,6 +302,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" @@ -396,6 +398,7 @@ async def test_query_message(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -405,6 +408,7 @@ async def test_query_message(hass: HomeAssistant) -> None: None, "Another Light", state=True, hs_color=(180, 75), ct=2500, brightness=78 ) light2.hass = hass + light2.platform = MockEntityPlatform(hass) light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" @@ -412,6 +416,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) light3.hass = hass + light3.platform = MockEntityPlatform(hass) light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" @@ -899,6 +904,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._available = False light._attr_device_info = None @@ -996,6 +1002,7 @@ async def test_device_class_switch( device_class=device_class, ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "switch.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1046,6 +1053,7 @@ async def test_device_class_binary_sensor( None, "Demo Sensor", state=False, device_class=device_class ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "binary_sensor.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1100,6 +1108,7 @@ async def test_device_class_cover( """Test that a cover entity syncs to the correct device type.""" sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "cover.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1150,6 +1159,7 @@ async def test_device_media_player( """Test that a binary entity syncs to the correct device type.""" sensor = AbstractDemoPlayer("Demo", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "media_player.demo" sensor.async_write_ha_state() @@ -1441,6 +1451,7 @@ async def test_sync_message_recovery( hs_color=(180, 75), ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index d269b5ff0d7d7c..dafe85d97b2f21 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2208,7 +2208,7 @@ async def test_fan_speed_ordered( "ordered": True, "speeds": [ { - "speed_name": f"{idx+1}/{len(speeds)}", + "speed_name": f"{idx + 1}/{len(speeds)}", "speed_values": [{"lang": "en", "speed_synonym": x}], } for idx, x in enumerate(speeds) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4192a60513e69d..df0b11487d8661 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -603,9 +603,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert ( "The user name is Test User." in mock_model.mock_calls[0][2]["system_instruction"] diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index af870a2136d8b0..18d96e3a1c05ed 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -12,7 +12,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import ( + MockEntityPlatform, + assert_setup_component, + async_fire_time_changed, +) NAME = "foo" @@ -111,11 +115,12 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: async_fire_time_changed(hass, shifted_time) -def test_name(requests_mock: requests_mock.Mocker) -> None: +def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] + sensor.platform = MockEntityPlatform(hass) test_name = value["name"] assert test_name == sensor.name diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 6c06171a45f8d3..5f0d54aaa0d9a1 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -7,7 +7,6 @@ from aioguardian.errors import GuardianError import pytest -from homeassistant.components import dhcp, zeroconf from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.components.guardian.config_flow import ( async_get_pin_from_discovery_hostname, @@ -17,6 +16,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -82,7 +83,7 @@ async def test_step_user(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf step.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -112,7 +113,7 @@ async def test_step_zeroconf(hass: HomeAssistant) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -138,7 +139,7 @@ async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_dhcp(hass: HomeAssistant) -> None: """Test the dhcp step.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -164,7 +165,7 @@ async def test_step_dhcp(hass: HomeAssistant) -> None: async def test_step_dhcp_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -193,7 +194,7 @@ async def test_step_dhcp_already_setup_match_mac(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", @@ -215,7 +216,7 @@ async def test_step_dhcp_already_setup_match_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 935a203f993b0f..daf1c66946354d 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -5,6 +5,7 @@ from habiticalib import ( BadRequestError, + HabiticaCastSkillResponse, HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, @@ -31,11 +32,13 @@ from tests.common import MockConfigEntry, load_fixture -ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message") +ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="reason") ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={}) ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={}) ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={}) -ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={}) +ERROR_TOO_MANY_REQUESTS = TooManyRequestsError( + error=ERROR_RESPONSE, headers={"retry-after": 5} +) @pytest.fixture(name="config_entry") @@ -91,8 +94,8 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("user.json", DOMAIN) ) - client.cast_skill.return_value = HabiticaUserResponse.from_json( - load_fixture("user.json", DOMAIN) + client.cast_skill.return_value = HabiticaCastSkillResponse.from_json( + load_fixture("cast_skill_response.json", DOMAIN) ) client.toggle_sleep.return_value = HabiticaSleepResponse( success=True, data=True diff --git a/tests/components/habitica/fixtures/cast_skill_response.json b/tests/components/habitica/fixtures/cast_skill_response.json new file mode 100644 index 00000000000000..41880770394e66 --- /dev/null +++ b/tests/components/habitica/fixtures/cast_skill_response.json @@ -0,0 +1,216 @@ +{ + "success": true, + "data": { + "user": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "tags": [ + { + "id": "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd", + "name": "Arbeit" + }, + { + "id": "6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab", + "name": "Training" + }, + { + "id": "20409521-c096-447f-9a90-23e8da615710", + "name": "Gesundheit + Wohlbefinden" + }, + { + "id": "2ac458af-0833-4f3f-bf04-98a0c33ef60b", + "name": "Schule" + }, + { + "id": "1bcb1a0f-4d05-4087-8223-5ea779e258b0", + "name": "Teams" + }, + { + "id": "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb", + "name": "Hausarbeiten" + }, + { + "id": "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "name": "Kreativität" + } + ], + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + }, + "balance": 10 + }, + "task": { + "_id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "date": "2024-08-31T22:16:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Rechnungen bezahlen", + "notes": "Strom- und Internetrechnungen rechtzeitig überweisen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [ + { + "id": "91c09432-10ac-4a49-bd20-823081ec29ed", + "time": "2024-09-22T02:00:00.0000Z" + } + ], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:19.513Z", + "updatedAt": "2024-09-21T22:19:35.576Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" + } + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_habitoween_base_pet", + "title": "Happy Habitoween!", + "text": "For this spooky celebration, you've received a Jack-O-Lantern Pet and an assortment of candy for your Pets!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "5af98f52-f72a-4540-bdeb-3ffc39b34196" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_harvestfeast_base_pet", + "title": "Happy Harvest Feast!", + "text": "Gobble gobble, you've received the Turkey Pet!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "1e4a1481-e7ca-42d1-9b3f-3f442bef9435" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "title": "Happy New Year!", + "destination": "/inventory/equipment", + "icon": "notif_2013hat_nye", + "text": "Take on your resolutions with style in this Absurd Party Hat!" + }, + "seen": false, + "id": "61fe229b-feee-48b9-bb4d-9e4b9c088ab1" + }, + { + "type": "NEW_STUFF", + "data": { + "title": "SNAG YOUR FAVES FROM THE QUEST SHOP - NEW SELECTIONS ARE ON THE WAY!" + }, + "seen": true, + "id": "0decabad-57f8-4cb2-a158-ba7b44da890f" + }, + { + "id": "f6fb06bc-6e63-40e1-b8c8-76bd5889ef51", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "a8289328-c2ae-4007-9ef4-833b9ac90d37", + "name": "tr4nt0r_2's Party" + } + }, + "seen": false + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 21 + }, + "seen": false, + "id": "a88ba092-2d4d-40f9-bf87-902aedf954fe" + } + ], + "userV": 677, + "appVersion": "5.32.5" +} diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index b4458aa647af68..e26dbeb17ccd11 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -370,7 +370,109 @@ "animalSetAchievements": {}, "stableAchievements": {}, "petSetCompleteAchievs": [], - "quests": {}, + "quests": { + "atom1": { + "text": "Angriff des Banalen, Teil 1: Abwasch-Katastrophe!", + "notes": "Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...", + "completion": "Nach gründlichem Schrubben ist das Geschirr sicher am Ufer gestapelt! Du trittst zurück und begutachtest stolz Deiner Hände Arbeit.", + "group": "questGroupAtom", + "prerequisite": { + "lvl": 15 + }, + "value": 4, + "lvl": 15, + "category": "unlockable", + "collect": { + "soapBars": { + "text": "Seifenstücke", + "count": 20 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "atom2", + "text": "Das Monster vom KochLess (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 7, + "exp": 50 + }, + "key": "atom1" + }, + "goldenknight1": { + "text": "Die goldene Ritterin, Teil 1: Ein ernstes Gespräch", + "notes": "Die goldene Ritterin ist Habiticanern mit ihrer Kritik ganz schön auf die Nerven gegangen. Nicht alle Tagesaufgaben erledigt? Eine negative Gewohnheit angeklickt? Sie nimmt dies zum Anlass Dich zu bedrängen, dass Du doch ihrem Beispiel folgen sollst. Sie ist das leuchtende Beispiel eines perfekten Habiticaners und Du bist nichts als ein Versager. Das ist ja mal gar nicht nett! Jeder macht Fehler. Man sollte deshalb nicht mit solcher Kritik drangsaliert werden. Vielleicht solltest Du einige Zeugenaussagen von verletzten Habiticanern zusammentragen und die Goldene Ritterin mal ordentlich zurechtweisen!", + "completion": "Schau Dir nur all diese Zeugenaussagen an! Bestimmt wird das reichen, um die Goldene Ritterin zu überzeugen. Nun musst Du sie nur noch finden.", + "group": "questGroupGoldenknight", + "value": 4, + "lvl": 40, + "category": "unlockable", + "collect": { + "testimony": { + "text": "Zeugenaussagen", + "count": 60 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "goldenknight2", + "text": "Die goldene Ritterin Teil 2: Die goldene Ritterin (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 15, + "exp": 120 + }, + "key": "goldenknight1" + }, + "dustbunnies": { + "text": "Die ungezähmten Staubmäuse", + "notes": "Es ist schon etwas her, seit Du hier drinnen das letzte Mal Staub gewischt hast, aber Du sorgst dich nicht allzusehr - ein Wenig Staub hat noch nie jemandem geschadet, oder? Erst, als Du Deine Hand in eine der staubigsten Ecken steckst und einen Biss spürst, erinnerst du dich an @InspectorCaracals Warnung: Harmlosen Staub zu lange in Ruhe zu lassen, verwandelt ihn in boshafte Staubmäuse! Du solltest sie besser besiegen, bevor sie ganz Habitica mit feinen Schmutzpartikeln bedecken!", + "group": "questGroupEarnable", + "completion": "Die Staubmäuse verschwinden in einer Rauch-, äh… Staubwolke. Als sich der Staub legt, siehst du dich um. Du hast vergessen, wie hübsch es hier doch aussieht, wenn es sauber ist. Du erkennst einen kleinen Haufen Gold, wo der Staub vorher war. Huch, du hattest dich schon gefragt, wo er abgeblieben war!", + "value": 1, + "category": "unlockable", + "boss": { + "name": "Ungezähmte Staubmäuse", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "dustbunnies" + }, + "basilist": { + "text": "Der Basi-List", + "notes": "Da ist ein Aufruhr auf dem Marktplatz – es sieht ganz so aus, als ob man lieber in die andere Richtung rennen sollte. Da Du aber ein mutiger Abenteurer bist, rennst Du stattdessen darauf zu und entdeckst einen Basi-List, der sich aus einem Haufen unerledigter Aufgaben geformt hat! Alle umstehenden Habiticaner sind aus Angst vor der Länge des Basi-Lists gelähmt und können nicht anfangen zu arbeiten. Von irgendwo in der Nähe hörst Du @Arcosine schreien: \"Schnell! Erledige Deine To-Dos und Tagesaufgaben, um dem Monster die Zähne zu entfernen, bevor sich jemand am Papier schneidet!\" Greife schnell an, Abenteurer, und hake etwas ab - aber Vorsicht! Wenn Du irgendwelche Tagesaufgaben nicht erledigst, wird der Basi-List Dich und Deine Party angreifen!", + "group": "questGroupEarnable", + "completion": "Der Basi-List ist in Papierschnitzel zerfallen, die sanft in Regenbogenfarben schimmern. \"Puh!\" sagt @Arcosine. \"Gut, dass ihr gerade hier wart!\" Du fühlst Dich erfahrener als vorher und sammelst ein paar verstreute Goldstücke zwischen den Papierstücken auf.", + "goldValue": 100, + "category": "unlockable", + "unlockCondition": { + "condition": "party invite", + "text": "Lade Freunde ein" + }, + "boss": { + "name": "Der Basi-List", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "basilist" + } + }, "questsByLevel": {}, "userCanOwnQuestCategories": [], "itemList": { @@ -450,11 +552,61 @@ "special": {}, "dropEggs": {}, "questEggs": {}, - "eggs": {}, + "eggs": { + "Wolf": { + "text": "Wolfsjunges", + "mountText": "Wolfs-Reittier", + "adjective": "ein treues", + "value": 3, + "key": "Wolf", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein treues Wolfsjunges schlüpfen kann." + }, + "TigerCub": { + "text": "Tigerjunges", + "mountText": "Tiger-Reittier", + "adjective": "ein wildes", + "value": 3, + "key": "TigerCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein wildes Tigerjunges schlüpfen kann." + }, + "PandaCub": { + "text": "Pandajunges", + "mountText": "Panda-Reittier", + "adjective": "ein sanftes", + "value": 3, + "key": "PandaCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein sanftes Pandajunges schlüpfen kann." + } + }, "dropHatchingPotions": {}, "premiumHatchingPotions": {}, "wackyHatchingPotions": {}, - "hatchingPotions": {}, + "hatchingPotions": { + "Base": { + "value": 2, + "key": "Base", + "text": "Normales", + "notes": "Gieße dies über ein Ei und es wird ein Normales Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "White": { + "value": 2, + "key": "White", + "text": "Weißes", + "notes": "Gieße dies über ein Ei und es wird ein Weißes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "Desert": { + "value": 2, + "key": "Desert", + "text": "Wüstenfarbenes", + "notes": "Gieße dies über ein Ei und es wird ein Wüstenfarbenes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + } + }, "pets": {}, "premiumPets": {}, "questPets": {}, @@ -466,7 +618,46 @@ "questMounts": {}, "specialMounts": {}, "mountInfo": {}, - "food": {} + "food": { + "Meat": { + "text": "Fleisch", + "textA": "Fleisch", + "textThe": "das Fleisch", + "target": "Base", + "value": 1, + "key": "Meat", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Milk": { + "text": "Milch", + "textA": "Milch", + "textThe": "die Milch", + "target": "White", + "value": 1, + "key": "Milk", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Potatoe": { + "text": "Kartoffel", + "textA": "eine Kartoffel", + "textThe": "die Kartoffel", + "target": "Desert", + "value": 1, + "key": "Potatoe", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Saddle": { + "sellWarningNote": "Hey! Das ist ein sehr nützlicher Gegenstand! Weißt Du, wie Du den Sattel mit Deinen Haustieren nutzt?", + "text": "Magischer Sattel", + "value": 5, + "notes": "Lässt eines Deiner Haustiere augenblicklich zum Reittier heranwachsen.", + "canDrop": false, + "key": "Saddle" + } + } }, "appVersion": "5.29.2" } diff --git a/tests/components/habitica/fixtures/duedate_fixture_9.json b/tests/components/habitica/fixtures/duedate_fixture_9.json new file mode 100644 index 00000000000000..f908ad0deaeb91 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_9.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-09-27T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 3bb646be512298..cf6e386467597d 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -598,6 +598,56 @@ "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "7d92278b-9361-4854-83b6-0a66b57dce20", + "frequency": "weekly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Lerne eine neue Programmiersprache", + "notes": "Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "7d92278b-9361-4854-83b6-0a66b57dce20" } ], "notifications": [ diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d97ad458c772bf..255d9c7c3b5318 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -112,8 +112,37 @@ "eyewear": "eyewear_armoire_plagueDoctorMask", "body": "body_special_aetherAmulet" } + }, + "quests": { + "atom1": 1, + "goldenknight1": 0, + "dustbunnies": 1, + "basilist": 0 + }, + "food": { + "Saddle": 2, + "Meat": 0, + "Milk": 1, + "Potatoe": 2 + }, + "hatchingPotions": { + "Base": 2, + "White": 0, + "Desert": 1 + }, + "eggs": { + "Wolf": 1, + "TigerCub": 0, + "PandaCub": 2 } }, - "balance": 10 + "balance": 10, + "purchased": { + "plan": { + "consecutive": { + "trinkets": 0 + } + } + } } } diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 7464a5fd36d028..9050db1946deec 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -43,6 +43,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItMTIgLTEyIDcyIDcyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZmlsbD0iIzFGNkVBMiIgZD0iTTI0LjQ2MiAxLjE1MWMtMy42OTUgMy43MTQtNy4zMjEgMTQuNjItOS4yMzYgMjAuOTEzLS44NC0xLjA2Mi0xLjc5OS0yLjA3Ni0zLjE1OC0zLjMyNi00LjgwNiAzLjUxNC03LjM1NyA3LjI1LTEwLjY0NiAxMS44MTYgOC45MTYgNy45MjYgMTEuMjMzIDkuMjU2IDIyLjk5NSAxNi4wMDVsLjA0NS0uMDI1aC4wMDFsLjA0NS4wMjVjMTEuNzYtNi43NDkgMTQuMDc5LTguMDggMjIuOTk0LTE2LjAwNS0zLjI4Ny00LjU2Ni01Ljg0MS04LjMwMi0xMC42NDUtMTEuODE2LTEuMzYxIDEuMjUtMi4zMTcgMi4yNjQtMy4xNTggMy4zMjYtMS45MTUtNi4yOTQtNS41NDMtMTcuMi05LjIzNi0yMC45MTNoLS4wMDF6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzI3OEFCRiIgZD0iTTI0LjQ2NCAzMi4xMDhsMy41NjEtMS4yNjVjNi4wNzUtLjYwNSA4LjQyNS0yLjI4MSA5LjQyNi0zLjQ5OGw2LjA1MyAyLjcwNy4wMDIuMDEzYS40NS40NSAwIDAgMS0uMTA1LjI4NiA0Ljk3IDQuOTcgMCAwIDEtLjMyLjMzYy0zLjQwNSAzLjMxNS0xOC4yMzcgMTEuOTgxLTE4LjYxNyAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM1M0I0RTUiIGQ9Ik0zNi40MDYgMjMuMDQxYy4yMy0uMTg4LjI5Mi0uMzQuNjc0LS4zMzYgMS4xODMuMDY3IDUuMTY5IDUuMTc1IDYuMDY0IDYuNDYxLjE5NC4yNzguMzY2LjYxLjM2Ljg4NWwtNi4wNTMtMi43MDdjLjIzNi0uMzY3LjE5LS45Mi0uMzU1LTEuMjUyLS44MTgtLjUwMS0yLjUyOS45NDktMi45NTkuMjg0LS41OTktLjkyNyAyLjA0LTMuMTQ3IDIuMjctMy4zMzUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMkFBMENGIiBkPSJNMjMuNTkxIDcuNDg3Yy01LjM5IDE1LjQxOS04LjIyNyAyMy44MTctOC4yMjcgMjMuODE3bDkuMDk3IDQuNzZWNi44MzljLS4zNiAwLS43MTguMjE2LS44Ny42NDgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMjc4QUJGIiBkPSJNMjUuMzMyIDcuNDg3Yy0uMTUyLS40MzItLjUxLS42NDgtLjg3LS42NDh2MjkuMjI1bDkuMDk3LTQuNzZzLTIuODM3LTguMzk4LTguMjI3LTIzLjgxNyIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM0REIyRDYiIGQ9Ik0yNC40NjIgMzIuMTA4TDIwLjkgMzAuODQzYy02LjA3NS0uNjA1LTguMzQyLTIuMzUyLTkuNDI1LTMuNDk4TDUuNDIgMzAuMDUybC0uMDAyLjAxM2EuNDUuNDUgMCAwIDAgLjEwNi4yODZjLjA5LjEwNC4xOTcuMjE1LjMxOC4zMyAzLjQwNiAzLjMxNSAxOC4yMzggMTEuOTgxIDE4LjYxOSAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM2QkM0RTkiIGQ9Ik0xMi41MTkgMjMuMDQxYy0uMjMtLjE4OC0uMjkyLS4zNC0uNjc0LS4zMzYtMS4xODMuMDY3LTUuMTY5IDUuMTc1LTYuMDYzIDYuNDYxLS4xOTQuMjc4LS4zNjcuNjEtLjM2MS44ODVsNi4wNTMtMi43MDdjLS4yMzYtLjM2Ny0uMTktLjkyLjM1Ni0xLjI1Mi44MTctLjUwMSAyLjUyOC45NDkgMi45NTguMjg0LjYtLjkyNy0yLjAzOS0zLjE0Ny0yLjI3LTMuMzM1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0E5REJGNiIgZD0iTTI0LjQ2MyAzNC45OTh2LTUuMTAxbC03LjIxLTQuMTMtMS40OCA0LjA0OXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjODRDRkYyIiBkPSJNMjQuNDYzIDM0Ljk5OHYtNS4xMDFsNy4yMDctNC4xMyAxLjQ4MyA0LjA0OXoiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Class', 'options': list([ 'warrior', @@ -160,6 +161,57 @@ 'state': 'test-user', }) # --- +# name: test_sensors[sensor.test_user_eggs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_eggs', + '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': 'Eggs', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', + 'unit_of_measurement': 'eggs', + }) +# --- +# name: test_sensors[sensor.test_user_eggs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Pandajunges': 2, + 'Tigerjunges': 0, + 'Wolfsjunges': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Egg.png', + 'friendly_name': 'test-user Eggs', + 'unit_of_measurement': 'eggs', + }), + 'context': , + 'entity_id': 'sensor.test_user_eggs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_experience-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -196,6 +248,7 @@ # name: test_sensors[sensor.test_user_experience-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Experience', 'unit_of_measurement': 'XP', }), @@ -297,6 +350,7 @@ # name: test_sensors[sensor.test_user_gold-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjRkZBNjIzIi8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTYuMyAxNy43Yy0zLjEtMy4xLTMuMS04LjIgMC0xMS4zIDMuMS0zLjEgOC4yLTMuMSAxMS4zIDAiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE3LjcgNi4zYzMuMSAzLjEgMy4xIDguMiAwIDExLjMtMy4xIDMuMS04LjIgMy4xLTExLjMgMCIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0JGN0QxQSIgZD0iTTEyIDJDNi41IDIgMiA2LjUgMiAxMnM0LjUgMTAgMTAgMTAgMTAtNC41IDEwLTEwUzE3LjUgMiAxMiAyem0wIDE4Yy00LjQgMC04LTMuNi04LThzMy42LTggOC04IDggMy42IDggOC0zLjYgOC04IDh6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCRjdEMUEiIGQ9Ik0xMyA5djJoLTJWOUg5djZoMnYtMmgydjJoMlY5eiIgb3BhY2l0eT0iLjc1Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Gold', 'unit_of_measurement': 'GP', }), @@ -514,6 +568,57 @@ 'state': '4', }) # --- +# name: test_sensors[sensor.test_user_hatching_potions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_hatching_potions', + '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': 'Hatching potions', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', + 'unit_of_measurement': 'potions', + }) +# --- +# name: test_sensors[sensor.test_user_hatching_potions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Normales': 2, + 'Weißes': 0, + 'Wüstenfarbenes': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_RoyalPurple.png', + 'friendly_name': 'test-user Hatching potions', + 'unit_of_measurement': 'potions', + }), + 'context': , + 'entity_id': 'sensor.test_user_hatching_potions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -553,6 +658,7 @@ # name: test_sensors[sensor.test_user_health-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Health', 'unit_of_measurement': 'HP', }), @@ -704,6 +810,7 @@ # name: test_sensors[sensor.test_user_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Mana', 'unit_of_measurement': 'MP', }), @@ -798,6 +905,7 @@ # name: test_sensors[sensor.test_user_max_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Max. mana', 'unit_of_measurement': 'MP', }), @@ -896,6 +1004,7 @@ # name: test_sensors[sensor.test_user_next_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Next level', 'unit_of_measurement': 'XP', }), @@ -962,6 +1071,109 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_user_pet_food-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_pet_food', + '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': 'Pet food', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', + 'unit_of_measurement': 'foods', + }) +# --- +# name: test_sensors[sensor.test_user_pet_food-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Fleisch': 0, + 'Kartoffel': 2, + 'Milch': 1, + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNTgiIHZpZXdCb3g9IjAgMCA2MCA1OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CjxyZWN0IHg9IjM4IiB5PSI2IiB3aWR0aD0iMjIiIGhlaWdodD0iMjYiIGZpbGw9InVybCgjcGF0dGVybjBfMTMyN18yNTYpIj48L3JlY3Q+CjxyZWN0IHdpZHRoPSIyNiIgaGVpZ2h0PSIyNiIgZmlsbD0idXJsKCNwYXR0ZXJuMV8xMzI3XzI1NikiPjwvcmVjdD4KPHJlY3QgeD0iOCIgeT0iMzYiIHdpZHRoPSIzMCIgaGVpZ2h0PSIyMiIgZmlsbD0idXJsKCNwYXR0ZXJuMl8xMzI3XzI1NikiPjwvcmVjdD4KPGRlZnM+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMF8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTBfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wNDU0NTQ1IDAuMDM4NDYxNSkiPjwvdXNlPgo8L3BhdHRlcm4+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMV8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTFfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wMzg0NjE1KSI+PC91c2U+CjwvcGF0dGVybj4KPHBhdHRlcm4gaWQ9InBhdHRlcm4yXzEzMjdfMjU2IiBwYXR0ZXJuQ29udGVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgd2lkdGg9IjEiIGhlaWdodD0iMSI+Cjx1c2UgeGxpbms6aHJlZj0iI2ltYWdlMl8xMzI3XzI1NiIgdHJhbnNmb3JtPSJzY2FsZSgwLjAzMzMzMzMgMC4wNDU0NTQ1KSI+PC91c2U+CjwvcGF0dGVybj4KPGltYWdlIGlkPSJpbWFnZTBfMTMyN18yNTYiIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNiIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFCWUFBQUFhQ0FZQUFBQ3pkcXhBQUFBQUFYTlNSMElBcnM0YzZRQUFBUWxKUkVGVVNBMjkwazFxd2xBVXhmRnNwK3NvWFlkem9lQWFXcUZiY1N4MTRMVFFTYnVBanJ1TFZnZFBudkFyY3NJalJsT0Z5OG45K3Q5alNOY04vQjd1N3N0cERJeWYzejZGMXVmek4yTXlRZnZmOTFMalo3czZSdlpqdlozbTR0Vmd3TjM2cTlUZ2tLclQ3K2VQVXNOZTA2b0JpNEJVblE2Q0UyaUJBcVhxVTV3LzV3b1dEVkwxVkgySzB3TWJBSGliZlpZYTZpMDFkenN3Nnk3bTUrVWZjTXhoUzNGd080WEp3SUNjNWVmRmFlcWc0NXVCMDVtODVWQ2R3ZDY3OVNxQVVnRmEyZ1FEV1RRb1Q4Mit2T2Q0Y3JBTEx0S3N6eDhYcFVhcmI3Nm5GcWdCK2NWZ29GVGdwK1ZMcVNIUHVkRTUwTCtCMTYrYlVzT2gwUTV6QVdoeXNFTU95Q2ZUc2VBRFNTQzNudTVpRmFjQUFBQUFTVVZPUks1Q1lJST0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UxXzEzMjdfMjU2IiB3aWR0aD0iMjYiIGhlaWdodD0iMjYiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQm9BQUFBYUNBWUFBQUNwU2t6T0FBQUFBWE5TUjBJQXJzNGM2UUFBQVNsSlJFRlVTQTNGMURGT3cwQVFCVkRUUmhTcDZSSFVhVkxRY0FXT0VDa05SNkhpUHRSY0psMUtxQlp0cEVmeGpiV3J4QTZXUmw4em52bC8vcTdsWVlqbmJuTmIvZ3B0K2U3NWZWMXFxT3Ryb29GRWcxay9XeWdKNzNjM3BRWUIrY1VDL3k2VVRqaDYyMjVMamQwd25NS2kzWmhITlpzUVlvaFl2djk0TERYVU9UbSt2cFFhWHc5UHAxQ2ZkSVFRSXBSZkxJUVFPbnZFTU92Nk9XazZNd0NUY0hZaFJ3UlR3QUtKN3VUNzgxQnFjS2orZTFlY0VJQ3pDeUdtdkZtdFNnMmJFN1JwYnE3UCs4bTd1cG9RSjVBalo1d2IyeHltZzNROCtjZTR1cENOYzhPV0EzT3c2VWpqNGtMdWlDQnNDYWZqcHFQRmhhYSt2bDVoanZYN3VQQ09VSU1CNkFnaDRzejE0eGtKWkVFamRPYUlXbWd1ZVVlNVJyaVlVQ29UN01XYzc4NTdCZlFoL2dGQ2FOUWFobDJ1M2dBQUFBQkpSVTVFcmtKZ2dnPT0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UyXzEzMjdfMjU2IiB3aWR0aD0iMzAiIGhlaWdodD0iMjIiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQjRBQUFBV0NBWUFBQURYWXl6UEFBQUFBWE5TUjBJQXJzNGM2UUFBQVJSSlJFRlVTQTNGMDhGdEFqRVVCRkNhZ0FZU0tkM1FBc1VnY1VrRHFTTzMxSkJqTHJuU0JsSms1TU9EMVdndDdSbzVJSDJOL2NlZStiUHNiamFEZjY4dmIyVmFnKzN1OGxQVHVyNHpLMWNwWko4eStoK0g3MUxMUHM4dDNoTklUQUY4dHpFQitQbHpLZFA2UGYrVld2akUwLzZyMUZvOVFBcE5UZXQ2dURIRDQvdTV6Qlhlb0ptME96SGhPZFBhd3o5c1RJQmdHbnJFeWV2dnR0dFNpMDVpdm95M3ZZTXBiQUFHeWVzL2JFeklmOFlJdGdZeCtDM0owb1dMdzR3WkpIcFVra2tzb1g0aW5sNHpxQU9KLzJic080TVNHa2d5aWZCUTMvbG1Vb1NERENGQi9IRGpOR1FNRFFDOWZIZ29XQk1kYkNYRlE0YXcyOWhFaEExZ2o0ZjZpZmpWU09ocHhnWllQWG52Qllhd1YyZnB2U3R4R0Z5dUlYV3NXUUFBQUFCSlJVNUVya0pnZ2c9PSI+PC9pbWFnZT4KPC9kZWZzPgo8L3N2Zz4=', + 'friendly_name': 'test-user Pet food', + 'unit_of_measurement': 'foods', + }), + 'context': , + 'entity_id': 'sensor.test_user_pet_food', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_quest_scrolls', + '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': 'Quest scrolls', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', + 'unit_of_measurement': 'scrolls', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!': 1, + 'Der Basi-List': 0, + 'Die goldene Ritterin, Teil 1: Ein ernstes Gespräch': 0, + 'Die ungezähmten Staubmäuse': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Quest scrolls', + 'unit_of_measurement': 'scrolls', + }), + 'context': , + 'entity_id': 'sensor.test_user_quest_scrolls', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1048,6 +1260,54 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_saddles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_saddles', + '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': 'Saddles', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', + 'unit_of_measurement': 'saddles', + }) +# --- +# name: test_sensors[sensor.test_user_saddles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Saddle.png', + 'friendly_name': 'test-user Saddles', + 'unit_of_measurement': 'saddles', + }), + 'context': , + 'entity_id': 'sensor.test_user_saddles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 3030b228d38402..d006221277506d 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1166,6 +1166,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), dict({ 'Type': , 'alias': None, @@ -3320,6 +3398,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4373,6 +4529,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4876,6 +5110,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 2597627062226e..9cd6d9a540fe8b 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,12 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'status': 'needs_action', + 'summary': 'Lerne eine neue Programmiersprache', + 'uid': '7d92278b-9361-4854-83b6-0a66b57dce20', + }), ]), }), }) @@ -144,7 +150,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '4', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index adce8dce0809b5..dc1a155b541238 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -4,6 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from habiticalib import HabiticaUserResponse, Skill import pytest @@ -215,12 +216,12 @@ async def test_button_press( [ ( ERROR_TOO_MANY_REQUESTS, - "Rate limit exceeded, try again later", + "Rate limit exceeded, try again in 5 seconds", HomeAssistantError, ), ( ERROR_BAD_REQUEST, - "Unable to connect to Habitica, try again later", + "Unable to connect to Habitica: reason", HomeAssistantError, ), ( @@ -228,6 +229,11 @@ async def test_button_press( "Unable to complete action, the required conditions are not met", ServiceValidationError, ), + ( + ClientError, + "Unable to connect to Habitica: ", + HomeAssistantError, + ), ], ) async def test_button_press_exceptions( diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index bd3287d3ea1887..07678b031bcd9d 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -9,6 +9,7 @@ CONF_API_USER, DEFAULT_URL, DOMAIN, + SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, ) @@ -54,6 +55,13 @@ SECTION_REAUTH_LOGIN: {}, SECTION_REAUTH_API_KEY: {CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382"}, } +USER_INPUT_RECONFIGURE = { + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + SECTION_DANGER_ZONE: { + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, + }, +} @pytest.mark.usefixtures("habitica") @@ -147,6 +155,35 @@ async def test_form_login_errors( assert result["result"].unique_id == TEST_API_USER +@pytest.mark.usefixtures("habitica") +async def test_form__already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("habitica") async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -387,3 +424,112 @@ async def test_flow_reauth_errors( assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_URL: DEFAULT_URL, + CONF_API_USER: "371fcad5-0f9c-4211-931c-034a5d2a6213", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + }, + unique_id="371fcad5-0f9c-4211-931c-034a5d2a6213", + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_REAUTH_LOGIN, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ERROR_NOT_AUTHORIZED, "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (KeyError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + habitica: AsyncMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + habitica.get_user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + habitica.get_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index ed2efd89f30346..e953ec254d6eae 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -4,6 +4,7 @@ import logging from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest @@ -85,11 +86,12 @@ async def test_service_call( @pytest.mark.parametrize( ("exception"), - [ - ERROR_BAD_REQUEST, - ERROR_TOO_MANY_REQUESTS, + [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], + ids=[ + "BadRequestError", + "TooManyRequestsError", + "ClientError", ], - ids=["BadRequestError", "TooManyRequestsError"], ) async def test_config_entry_not_ready( hass: HomeAssistant, @@ -131,14 +133,16 @@ async def test_config_entry_auth_failed( assert flow["context"].get("entry_id") == config_entry.entry_id +@pytest.mark.parametrize("exception", [ERROR_NOT_FOUND, ClientError]) async def test_coordinator_update_failed( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, ) -> None: """Test coordinator update failed.""" - habitica.get_tasks.side_effect = ERROR_NOT_FOUND + habitica.get_tasks.side_effect = exception 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/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 9dde266d214545..1c648e3872028b 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -6,10 +6,13 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.sensor import HabiticaSensorEntity +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry, snapshot_platform @@ -33,6 +36,19 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" + for entity in ( + ("test_user_habits", "habits"), + ("test_user_rewards", "rewards"), + ("test_user_max_health", "health_max"), + ): + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", + suggested_object_id=entity[0], + disabled_by=None, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -40,3 +56,96 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deprecation issue.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=None, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + 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 + + assert entity_registry.async_get(f"sensor.{entity_id}") is not None + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_delete_disabled( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deletion .""" + + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + 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 + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + is None + ) + + assert entity_registry.async_get(f"sensor.{entity_id}") is None diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3ada16b9735fe8..5fca1884bdff39 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID +from aiohttp import ClientError from habiticalib import Direction, Skill import pytest from syrupy.assertion import SnapshotAssertion @@ -46,8 +47,8 @@ from tests.common import MockConfigEntry -REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" -RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @pytest.fixture(autouse=True) @@ -235,6 +236,15 @@ async def test_cast_skill( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) async def test_cast_skill_exceptions( @@ -360,6 +370,11 @@ async def test_handle_quests( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) @pytest.mark.parametrize( @@ -520,6 +535,15 @@ async def test_score_task( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ( { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", @@ -722,7 +746,7 @@ async def test_transformation( ERROR_BAD_REQUEST, None, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ( { @@ -752,7 +776,27 @@ async def test_transformation( None, ERROR_BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + None, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + ClientError, + None, + HomeAssistantError, + "Unable to connect to Habitica: ", ), ], ) diff --git a/tests/components/habitica/test_switch.py b/tests/components/habitica/test_switch.py index c259f53f1838ab..1799788a48e48d 100644 --- a/tests/components/habitica/test_switch.py +++ b/tests/components/habitica/test_switch.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from syrupy.assertion import SnapshotAssertion @@ -96,6 +97,7 @@ async def test_turn_on_off_toggle( [ (ERROR_TOO_MANY_REQUESTS, HomeAssistantError), (ERROR_BAD_REQUEST, HomeAssistantError), + (ClientError, HomeAssistantError), ], ) async def test_turn_on_off_toggle_exceptions( diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index ea81701316992b..8f20b3e685a1da 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -23,10 +23,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import ERROR_NOT_FOUND +from .conftest import ERROR_NOT_FOUND, ERROR_TOO_MANY_REQUESTS from tests.common import ( MockConfigEntry, @@ -183,12 +183,30 @@ async def test_uncomplete_todo_item( ], ids=["completed", "needs_action"], ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + r"Unable to update the score for your Habitica to-do `.+`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_complete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, uid: str, status: str, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when completing/uncompleting an item on the todo list.""" @@ -198,10 +216,10 @@ async def test_complete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_score.side_effect = ERROR_NOT_FOUND + habitica.update_score.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match=r"Unable to update the score for your Habitica to-do `.+`, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -311,10 +329,28 @@ async def test_update_todo_item( habitica.update_task.assert_awaited_once_with(*call_args) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to update the Habitica to-do `test-summary`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_update_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when update item on the todo list.""" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" @@ -324,11 +360,8 @@ async def test_update_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_task.side_effect = ERROR_NOT_FOUND - with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to update the Habitica to-do `test-summary`, please try again", - ): + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception=expected_exception, match=exc_msg): await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -378,10 +411,28 @@ async def test_add_todo_item( ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to create new to-do `test-summary` for Habitica, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_add_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when adding a todo item to the todo list.""" @@ -391,10 +442,11 @@ async def test_add_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.create_task.side_effect = ERROR_NOT_FOUND + habitica.create_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to create new to-do `test-summary` for Habitica, please try again", + expected_exception=expected_exception, + # match="Unable to create new to-do `test-summary` for Habitica, please try again", + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -434,10 +486,28 @@ async def test_delete_todo_item( habitica.delete_task.assert_awaited_once_with(UUID(uid)) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete item from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting a todo item from the todo list.""" @@ -448,11 +518,11 @@ async def test_delete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_task.side_effect = ERROR_NOT_FOUND + habitica.delete_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete item from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -486,10 +556,28 @@ async def test_delete_completed_todo_items( habitica.delete_completed_todos.assert_awaited_once() +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete completed to-do items from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_completed_todo_items_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -498,10 +586,10 @@ async def test_delete_completed_todo_items_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND + habitica.delete_completed_todos.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete completed to-do items from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -575,11 +663,26 @@ async def test_move_todo_item( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) +@pytest.mark.parametrize( + ("exception", "exc_msg"), + [ + ( + ERROR_NOT_FOUND, + "Unable to move the Habitica to-do to position 0, please try again", + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + ), + ], +) async def test_move_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, hass_ws_client: WebSocketGenerator, + exception: Exception, + exc_msg: str, ) -> None: """Test exception when moving todo item.""" @@ -590,7 +693,7 @@ async def test_move_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.reorder_task.side_effect = ERROR_NOT_FOUND + habitica.reorder_task.side_effect = exception client = await hass_ws_client() data = { @@ -605,10 +708,7 @@ async def test_move_todo_item_exception( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) assert resp["success"] is False - assert ( - resp["error"]["message"] - == "Unable to move the Habitica to-do to position 0, please try again" - ) + assert resp["error"]["message"] == exc_msg @pytest.mark.parametrize( @@ -622,6 +722,7 @@ async def test_move_todo_item_exception( ("duedate_fixture_6.json", "2024-10-21"), ("duedate_fixture_7.json", None), ("duedate_fixture_8.json", None), + ("duedate_fixture_9.json", None), ], ids=[ "default", @@ -632,6 +733,7 @@ async def test_move_todo_item_exception( "monthly starts on fixed day", "grey daily", "empty nextDue", + "grey daily no weekdays", ], ) @pytest.mark.usefixtures("set_tz") diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index d87bfd32326db9..2233ad194f5246 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -5,12 +5,12 @@ import aiohttp from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.harmony.config_flow import CannotConnect from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -65,7 +65,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -120,7 +120,7 @@ async def test_form_ssdp_fails_to_get_remote_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -159,7 +159,7 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.2.2.2:8088/description", diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 10a804d983fc00..40ab253b7e6a62 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -673,7 +673,7 @@ async def test_agents_notify_on_mount_added_removed( "instance_id": ANY, "with_automatic_settings": False, }, - folders=None, + folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, location=[None], @@ -704,7 +704,7 @@ async def test_agents_notify_on_mount_added_removed( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), ), ( { diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 6451f5bc69e161..1348923927bbda 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,21 +3,27 @@ from __future__ import annotations from collections.abc import Sequence -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from pyheos import ( + CONTROLS_ALL, Dispatcher, Heos, HeosGroup, + HeosOptions, HeosPlayer, - HeosSource, - InputSource, + LineOutLevelType, + MediaItem, + MediaType, + NetworkType, + PlayerUpdateResult, + PlayState, + RepeatType, const, ) import pytest import pytest_asyncio -from homeassistant.components import ssdp from homeassistant.components.heos import ( CONF_PASSWORD, DOMAIN, @@ -27,6 +33,16 @@ SourceManager, ) from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -79,26 +95,27 @@ def controller_fixture( players, favorites, input_sources, playlists, change_data, dispatcher, group ): """Create a mock Heos controller fixture.""" - mock_heos = Mock(Heos) + mock_heos = Heos(HeosOptions(host="127.0.0.1", dispatcher=dispatcher)) for player in players.values(): player.heos = mock_heos - mock_heos.return_value = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players - mock_heos.get_favorites.return_value = favorites - mock_heos.get_input_sources.return_value = input_sources - mock_heos.get_playlists.return_value = playlists - mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED - mock_heos.get_groups.return_value = group - mock_heos.create_group.return_value = None - + mock_heos.connect = AsyncMock() + mock_heos.disconnect = AsyncMock() + mock_heos.sign_in = AsyncMock() + mock_heos.sign_out = AsyncMock() + mock_heos.get_players = AsyncMock(return_value=players) + mock_heos._players = players + mock_heos.get_favorites = AsyncMock(return_value=favorites) + mock_heos.get_input_sources = AsyncMock(return_value=input_sources) + mock_heos.get_playlists = AsyncMock(return_value=playlists) + mock_heos.load_players = AsyncMock(return_value=change_data) + mock_heos._signed_in_username = "user@user.com" + mock_heos.get_groups = AsyncMock(return_value=group) + mock_heos.create_group = AsyncMock(return_value=None) + new_mock = Mock(return_value=mock_heos) + mock_heos.new_mock = new_mock with ( - patch("homeassistant.components.heos.Heos", new=mock_heos), - patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos), + patch("homeassistant.components.heos.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), ): yield mock_heos @@ -114,23 +131,25 @@ def player_fixture(quick_selects): """Create two mock HeosPlayers.""" players = {} for i in (1, 2): - player = Mock(HeosPlayer) - player.player_id = i - if i > 1: - player.name = f"Test Player {i}" - else: - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PLAY_STATE_STOP - player.ip_address = f"127.0.0.{i}" - player.network = "wired" - player.shuffle = False - player.repeat = const.REPEAT_OFF - player.volume = 25 - player.now_playing_media.supported_controls = const.CONTROLS_ALL + player = HeosPlayer( + player_id=i, + name="Test Player" if i == 1 else f"Test Player {i}", + model="HEOS Drive HS2" if i == 1 else "Speaker", + serial="123456", + version="1.0.0", + line_out=LineOutLevelType.VARIABLE, + is_muted=False, + available=True, + state=PlayState.STOP, + ip_address=f"127.0.0.{i}", + network=NetworkType.WIRED, + shuffle=False, + repeat=RepeatType.OFF, + volume=25, + heos=None, + ) + player.now_playing_media = Mock() + player.now_playing_media.supported_controls = CONTROLS_ALL player.now_playing_media.album_id = 1 player.now_playing_media.queue_id = 1 player.now_playing_media.source_id = 1 @@ -143,42 +162,77 @@ def player_fixture(quick_selects): player.now_playing_media.current_position = None player.now_playing_media.image_url = "http://" player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects + player.add_to_queue = AsyncMock() + player.clear_queue = AsyncMock() + player.get_quick_selects = AsyncMock(return_value=quick_selects) + player.mute = AsyncMock() + player.pause = AsyncMock() + player.play = AsyncMock() + player.play_input_source = AsyncMock() + player.play_next = AsyncMock() + player.play_previous = AsyncMock() + player.play_preset_station = AsyncMock() + player.play_quick_select = AsyncMock() + player.play_url = AsyncMock() + player.set_mute = AsyncMock() + player.set_play_mode = AsyncMock() + player.set_quick_select = AsyncMock() + player.set_volume = AsyncMock() + player.stop = AsyncMock() + player.unmute = AsyncMock() players[player.player_id] = player return players @pytest.fixture(name="group") -def group_fixture(players): +def group_fixture(): """Create a HEOS group consisting of two players.""" - group = Mock(HeosGroup) - group.leader = players[1] - group.members = [players[2]] - group.group_id = 999 + group = HeosGroup( + name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] + ) + return {group.group_id: group} @pytest.fixture(name="favorites") -def favorites_fixture() -> dict[int, HeosSource]: +def favorites_fixture() -> dict[int, MediaItem]: """Create favorites fixture.""" - station = Mock(HeosSource) - station.type = const.TYPE_STATION - station.name = "Today's Hits Radio" - station.media_id = "123456789" - radio = Mock(HeosSource) - radio.type = const.TYPE_STATION - radio.name = "Classical MPR (Classical Music)" - radio.media_id = "s1234" + station = MediaItem( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Today's Hits Radio", + media_id="123456789", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) + radio = MediaItem( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="Classical MPR (Classical Music)", + media_id="s1234", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return {1: station, 2: radio} @pytest.fixture(name="input_sources") -def input_sources_fixture() -> Sequence[InputSource]: +def input_sources_fixture() -> Sequence[MediaItem]: """Create a set of input sources for testing.""" - source = Mock(InputSource) - source.player_id = 1 - source.input_name = const.INPUT_AUX_IN_1 - source.name = "HEOS Drive - Line In 1" + source = MediaItem( + source_id=1, + name="HEOS Drive - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return [source] @@ -191,18 +245,18 @@ async def dispatcher_fixture() -> Dispatcher: @pytest.fixture(name="discovery_data") def discovery_data_fixture() -> dict: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Office", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @@ -210,18 +264,18 @@ def discovery_data_fixture() -> dict: @pytest.fixture(name="discovery_data_bedroom") def discovery_data_fixture_bedroom() -> dict: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.2:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Bedroom", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Bedroom", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @@ -240,21 +294,27 @@ def quick_selects_fixture() -> dict[int, str]: @pytest.fixture(name="playlists") -def playlists_fixture() -> Sequence[HeosSource]: +def playlists_fixture() -> Sequence[MediaItem]: """Create favorites fixture.""" - playlist = Mock(HeosSource) - playlist.type = const.TYPE_PLAYLIST - playlist.name = "Awesome Music" + playlist = MediaItem( + source_id=const.MUSIC_SOURCE_PLAYLISTS, + name="Awesome Music", + type=MediaType.PLAYLIST, + playable=True, + browsable=True, + image_url="", + heos=None, + ) return [playlist] @pytest.fixture(name="change_data") def change_data_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {}, const.DATA_NEW: []} + return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") def change_data_mapped_ids_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {101: 1}, const.DATA_NEW: []} + return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 45c2fbf4eb149e..217c7393e1480e 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,14 +1,15 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandFailedError, HeosError +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest -from homeassistant.components import heos, ssdp +from homeassistant.components import heos from homeassistant.components.heos.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -86,8 +87,8 @@ async def test_create_entry_when_friendly_name_valid( async def test_discovery_shows_create_form( hass: HomeAssistant, controller, - discovery_data: ssdp.SsdpServiceInfo, - discovery_data_bedroom: ssdp.SsdpServiceInfo, + discovery_data: SsdpServiceInfo, + discovery_data_bedroom: SsdpServiceInfo, ) -> None: """Test discovery shows form to confirm setup.""" @@ -112,7 +113,7 @@ async def test_discovery_shows_create_form( async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo, config_entry + hass: HomeAssistant, controller, discovery_data: SsdpServiceInfo, config_entry ) -> None: """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) @@ -199,14 +200,9 @@ async def test_reconfigure_cannot_connect_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], @@ -337,14 +333,9 @@ async def test_options_flow_missing_one_param_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), - "invalid_auth", - ), - ( - CommandFailedError("sign_in", "User not logged in", 8), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 905346b8b4a563..f802529ac82e12 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -4,7 +4,7 @@ from typing import cast from unittest.mock import Mock, patch -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandFailedError, HeosError, SignalHeosEvent, SignalType, const import pytest from homeassistant.components.heos import ( @@ -19,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -82,7 +83,7 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.call_args[0][0]) + options = cast(HeosOptions, controller.new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] @@ -103,10 +104,9 @@ async def test_async_setup_entry_auth_failure_starts_reauth( # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None controller.dispatcher.send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) controller.connect.side_effect = connect_send_auth_failure @@ -133,8 +133,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None with patch.object( hass.config_entries, "async_forward_entry_setups" ) as forward_mock: @@ -213,9 +212,48 @@ async def test_update_sources_retry( source_manager.max_retry_attempts = 1 controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) # Wait until it's finished while "Unable to update sources" not in caplog.text: await asyncio.sleep(0.1) assert controller.get_favorites.call_count == 2 + + +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test device information populates correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device.manufacturer == "HEOS" + assert device.model == "Drive HS2" + assert device.name == "Test Player" + assert device.serial_number == "123456" + assert device.sw_version == "1.0.0" + device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device.manufacturer == "HEOS" + assert device.model == "Speaker" + + +async def test_device_id_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string device identifiers are migrated to strings.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert device_registry.async_get_device({("Other", 1)}) is not None + assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 355cb47a0d9201..e71614564f27d8 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -3,8 +3,15 @@ import asyncio from typing import Any -from pyheos import CommandFailedError, const -from pyheos.error import HeosError +from pyheos import ( + AddCriteriaType, + CommandFailedError, + HeosError, + PlayState, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos import media_player @@ -115,18 +122,18 @@ async def test_updates_from_signals( player = controller.players[1] # Test player does not update for other players - player.state = const.PLAY_STATE_PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PLAY_STATE_PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -137,7 +144,7 @@ async def test_updates_from_signals( player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) @@ -167,7 +174,7 @@ async def set_signal(): # Connected player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -175,10 +182,9 @@ async def set_signal(): # Disconnected event.clear() - player.reset_mock() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_UNAVAILABLE @@ -186,11 +192,10 @@ async def set_signal(): # Connected handles refresh failure event.clear() - player.reset_mock() controller.load_players.reset_mock() controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -213,7 +218,7 @@ async def set_signal(): input_sources.clear() player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -241,9 +246,9 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PLAY_STATE_PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await event.wait() await hass.async_block_till_done() @@ -266,7 +271,7 @@ async def test_updates_from_players_changed_new_ids( event = asyncio.Event() # Assert device registry matches current id - assert device_registry.async_get_device(identifiers={(DOMAIN, 1)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) # Assert entity registry matches current id assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") @@ -279,7 +284,7 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) @@ -287,7 +292,7 @@ async def set_signal(): # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 - assert device_registry.async_get_device(identifiers={(DOMAIN, 101)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) # Assert entity registry unique id was updated assert len(entity_registry.entities) == 2 assert ( @@ -309,10 +314,9 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None + SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -551,11 +555,11 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(1) + player.play_preset_station.assert_called_once_with(1) # Test state is matched by station name player.now_playing_media.station = favorite.name player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -576,12 +580,12 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + player.play_preset_station.assert_called_once_with(2) # Test state is matched by album id player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -601,14 +605,14 @@ async def test_select_radio_favorite_command_error( player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_favorite.side_effect = CommandFailedError(None, "Failure", 1) + player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + player.play_preset_station.assert_called_once_with(2) assert "Unable to select source: Failure (1)" in caplog.text @@ -629,12 +633,12 @@ async def test_select_input_source( }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) + player.play_input_source.assert_called_once_with(input_source.media_id) # Test state is matched by media id player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.media_id = const.INPUT_AUX_IN_1 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -681,7 +685,7 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) + player.play_input_source.assert_called_once_with(input_source.media_id) assert "Unable to select source: Failure (1)" in caplog.text @@ -831,7 +835,7 @@ async def test_play_media_playlist( blocking=True, ) player.add_to_queue.assert_called_once_with( - playlist, const.ADD_QUEUE_REPLACE_AND_PLAY + playlist, AddCriteriaType.REPLACE_AND_PLAY ) # Play with enqueuing player.add_to_queue.reset_mock() @@ -846,7 +850,7 @@ async def test_play_media_playlist( }, blocking=True, ) - player.add_to_queue.assert_called_once_with(playlist, const.ADD_QUEUE_ADD_TO_END) + player.add_to_queue.assert_called_once_with(playlist, AddCriteriaType.ADD_TO_END) # Invalid name player.add_to_queue.reset_mock() await hass.services.async_call( @@ -888,9 +892,9 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) + player.play_preset_station.assert_called_once_with(index) # Play by name - player.play_favorite.reset_mock() + player.play_preset_station.reset_mock() await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -901,9 +905,9 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) + player.play_preset_station.assert_called_once_with(index) # Invalid name - player.play_favorite.reset_mock() + player.play_preset_station.reset_mock() await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -914,7 +918,7 @@ async def test_play_media_favorite( }, blocking=True, ) - assert player.play_favorite.call_count == 0 + assert player.play_preset_station.call_count == 0 assert "Unable to play media: Invalid favorite 'Invalid'" in caplog.text @@ -1026,7 +1030,7 @@ async def test_media_player_unjoin_group( player = controller.players[1] player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED, ) diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index d8b8b5038b0941..175e072e8e7b20 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -11,6 +11,7 @@ SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -37,30 +38,14 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: controller.sign_in.assert_called_once_with("test@test.com", "password") -async def test_sign_in_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) - - assert controller.sign_in.call_count == 0 - assert "Unable to sign in because HEOS is not connected" in caplog.text - - async def test_sign_in_failed( hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture ) -> None: """Test sign-in service logs error when not connected.""" await setup_component(hass, config_entry) - controller.sign_in.side_effect = CommandFailedError("", "Invalid credentials", 6) + controller.sign_in.side_effect = CommandAuthenticationError( + "", "Invalid credentials", 6 + ) await hass.services.async_call( DOMAIN, @@ -91,6 +76,20 @@ async def test_sign_in_unknown_error( assert "Unable to sign in" in caplog.text +async def test_sign_in_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: + """Test the sign-in service when entry not loaded raises exception.""" + await setup_component(hass, config_entry) + await hass.config_entries.async_unload(config_entry.entry_id) + + with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) + + async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: """Test the sign-out service.""" await setup_component(hass, config_entry) @@ -100,17 +99,13 @@ async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: assert controller.sign_out.call_count == 1 -async def test_sign_out_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test the sign-out service.""" +async def test_sign_out_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: + """Test the sign-out service when entry not loaded raises exception.""" await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + await hass.config_entries.async_unload(config_entry.entry_id) - assert controller.sign_out.call_count == 0 - assert "Unable to sign out because HEOS is not connected" in caplog.text + with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) async def test_sign_out_unknown_error( diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 7d5843e9525245..af975979196273 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -10,11 +10,16 @@ BSH_ACTIVE_PROGRAM, BSH_SELECTED_PROGRAM, ) -from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import get_all_appliances @@ -52,6 +57,40 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +async def test_filter_unknown_programs( + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: Mock, + appliance: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select that programs that are not part of the official Home Connect API specification are filtered out. + + We use two programs to ensure that programs are iterated over a copy of the list, + and it does not raise problems when removing an element from the original list. + """ + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [ + PROGRAM, + "NonOfficialProgram", + "AntotherNonOfficialProgram", + ] + get_appliances.return_value = [appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get("select.washer_selected_program") + assert entity + assert entity.capabilities.get(ATTR_OPTIONS) == [ + "dishcare_dishwasher_program_eco_50" + ] + + @pytest.mark.parametrize( ("entity_id", "status", "program_to_set"), [ diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index a02cb553ece84c..9d54feeaa548a3 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -13,7 +13,6 @@ ATTR_CONSTRAINTS, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, @@ -376,32 +375,6 @@ async def test_ent_desc_switch_exception_handling( STATE_OFF, "Dishwasher", ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Run" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Inactive" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_OFF, - "Dishwasher", - ), ( "switch.dishwasher_power", {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 56eeb4177b183d..0aed3dc929ee95 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -38,6 +38,7 @@ from tests.common import ( MockConfigEntry, + MockEntityPlatform, MockUser, async_capture_events, async_mock_service, @@ -90,6 +91,8 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.entity" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("test.entity") diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 85882274fec694..fe4fb53962a7f5 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1770,8 +1770,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1938,8 +1937,7 @@ async def test_variables_priority( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 83157a158a6db0..c3117bbb6603c6 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -1423,8 +1423,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1727,8 +1726,7 @@ async def test_variables_priority( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 055b63472670b6..904fcac321c7b3 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -4,7 +4,6 @@ import pytest -from homeassistant.components import usb from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, @@ -17,10 +16,11 @@ from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry -USB_DATA_SKY = usb.UsbServiceInfo( +USB_DATA_SKY = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -29,7 +29,7 @@ description="SkyConnect v1.0", ) -USB_DATA_ZBT1 = usb.UsbServiceInfo( +USB_DATA_ZBT1 = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -47,7 +47,7 @@ ], ) async def test_config_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +102,7 @@ async def mock_async_step_pick_firmware_zigbee(self, data): ], ) async def test_options_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the options flow for SkyConnect.""" config_entry = MockConfigEntry( @@ -168,7 +168,7 @@ async def mock_async_step_pick_firmware_zigbee(self, data): ], ) async def test_options_flow_multipan_uninstall( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test options flow for when multi-PAN firmware is installed.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 1d1d70c1b4c547..2801b3d00bb43e 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -8,7 +8,7 @@ get_hardware_variant, get_usb_service_info, ) -from homeassistant.components.usb import UsbServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index 881a24656f3ddc..a777f6b59a9447 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Homee integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from typing_extensions import Generator from homeassistant.components.homee.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index d6d0c7118dbb9d..c1b4ecfed707db 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1406,7 +1406,6 @@ async def test_options_flow_exclude_mode_skips_category_entities( "switch", "sonos", "config", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(sonos_config_switch.entity_id, "off") @@ -1415,7 +1414,6 @@ async def test_options_flow_exclude_mode_skips_category_entities( "switch", "sonos", "notconfig", - device_id="1234", entity_category=None, ) hass.states.async_set(sonos_notconfig_switch.entity_id, "off") @@ -1510,7 +1508,6 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "switch", "sonos", "config", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") @@ -1594,7 +1591,6 @@ async def test_options_flow_include_mode_allows_hidden_entities( "switch", "sonos", "config", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 4000c61e422b56..0829c96ce1d6f2 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -615,7 +615,6 @@ async def test_homekit_entity_glob_filter_with_config_entities( "select", "any", "any", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(select_config_entity.entity_id, "off") @@ -624,7 +623,6 @@ async def test_homekit_entity_glob_filter_with_config_entities( "switch", "any", "any", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(switch_config_entity.entity_id, "off") @@ -669,7 +667,6 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( "select", "any", "any", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(select_config_entity.entity_id, "off") @@ -678,7 +675,6 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( "switch", "any", "any", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(switch_config_entity.entity_id, "off") @@ -1867,7 +1863,11 @@ async def test_homekit_ignored_missing_devices( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test HomeKit handles a device in the entity registry but missing from the device registry.""" + """Test HomeKit handles a device in the entity registry but missing from the device registry. + + If the entity registry is updated to remove entities linked to non-existent devices, + or set the link to None, this test can be removed. + """ entry = await async_init_integration(hass) homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) @@ -1885,47 +1885,37 @@ async def test_homekit_ignored_missing_devices( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + binary_sensor_entity = entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - entity_registry.async_get_or_create( + sensor_entity = entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_registry.async_get_or_create( + light_entity = light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) # Delete the device to make sure we fallback # to using the platform - device_registry.async_remove_device(device_entry.id) - # Wait for the entities to be removed - await asyncio.sleep(0) - await asyncio.sleep(0) - # Restore the registry - entity_registry.async_get_or_create( - "binary_sensor", - "powerwall", - "battery_charging", - device_id=device_entry.id, - original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - ) - entity_registry.async_get_or_create( - "sensor", - "powerwall", - "battery", - device_id=device_entry.id, - original_device_class=SensorDeviceClass.BATTERY, - ) - light = entity_registry.async_get_or_create( - "light", "powerwall", "demo", device_id=device_entry.id - ) + with patch( + "homeassistant.helpers.entity_registry.async_entries_for_device", + return_value=[], + ): + device_registry.async_remove_device(device_entry.id) + # Wait for the device registry event handlers to execute + await asyncio.sleep(0) + await asyncio.sleep(0) + # Check the entities were not removed + assert binary_sensor_entity.entity_id in entity_registry.entities + assert sensor_entity.entity_id in entity_registry.entities + assert light_entity.entity_id in entity_registry.entities hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index fb059b93a13d5e..c1870cecd9c5bb 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -540,6 +541,422 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e99db8f6234aa0..fc4cfa78ca408d 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 4fb0a80cd26a0b..424f93f7142418 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -15,7 +15,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.components.homekit_controller.storage import async_get_entity_storage @@ -23,6 +22,10 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -176,9 +179,9 @@ def get_flow_context( def get_device_discovery_info( device, upper_case_props=False, missing_csharp=False, paired=False -) -> zeroconf.ZeroconfServiceInfo: +) -> ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" - result = zeroconf.ZeroconfServiceInfo( + result = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, @@ -187,7 +190,7 @@ def get_device_discovery_info( properties={ "md": device.description.model, "pv": "1.0", - zeroconf.ATTR_PROPERTIES_ID: device.description.id, + ATTR_PROPERTIES_ID: device.description.id, "c#": device.description.config_num, "s#": device.description.state_num, "ff": "0", @@ -330,7 +333,7 @@ async def test_id_missing(hass: HomeAssistant, controller) -> None: discovery_info = get_device_discovery_info(device) # Remove id from device - del discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + del discovery_info.properties[ATTR_PROPERTIES_ID] # Device is discovered result = await hass.config_entries.flow.async_init( @@ -346,7 +349,7 @@ async def test_discovery_ignored_model(hass: HomeAssistant, controller) -> None: """Already paired.""" device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" discovery_info.properties["md"] = "HHKBridge1,1" # Device is discovered @@ -375,7 +378,7 @@ async def test_discovery_ignored_hk_bridge( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -403,7 +406,7 @@ async def test_discovery_does_not_ignore_non_homekit( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -582,7 +585,7 @@ async def test_discovery_already_configured_update_csharp( # Set device as already paired discovery_info.properties["sf"] = 0x00 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -967,7 +970,7 @@ async def test_discovery_dismiss_existing_flow_on_paired( # Set device as already not paired discovery_info.properties["sf"] = 0x01 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1201,7 +1204,7 @@ async def test_discovery_updates_ip_when_config_entry_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1239,7 +1242,7 @@ async def test_discovery_updates_ip_config_entry_not_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 7a3d3f06b0956b..ff57cd168c959b 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8177,6 +8177,125 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000ESIIE3", "type": "ENERGY_SENSORS_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000DSDPCB": { + "availableFirmwareVersion": "1.0.6", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000DSDPCB", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000042"], + "index": 0, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -58, + "rssiPeerValue": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_CLOSE", + "channelRole": "DOOR_BELL_INPUT", + "corrosionPreventionActive": false, + "deviceId": "3014F7110000000000DSDPCB", + "doorBellSensorEventTimestamp": 1673006015756, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 1, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "supportedOptionalFeatures": { + "IFeatureAccessAuthorizationSensorChannel": false, + "IFeatureGarageGroupSensorChannel": true, + "IFeatureLightGroupSensorChannel": false, + "IFeatureShadingGroupSensorChannel": false, + "IOptionalFeatureDoorBellSensorEventTimestamp": true, + "IOptionalFeatureEventDelay": false, + "IOptionalFeatureLongPressSupported": false, + "IOptionalFeatureWindowState": false + }, + "windowState": "CLOSED" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000DSDPCB", + "label": "dsdpcb_klingel", + "lastStatusUpdate": 1673006015756, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 410, + "modelType": "HmIP-DSD-PCB", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", + "type": "DOOR_BELL_CONTACT_INTERFACE", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5b4993f7314ac7..5ec37d8d8f5399 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -28,7 +28,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 308 + assert len(mock_hap.hmip_device_by_entity_id) == 310 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_event.py b/tests/components/homematicip_cloud/test_event.py new file mode 100644 index 00000000000000..de615b35808ab2 --- /dev/null +++ b/tests/components/homematicip_cloud/test_event.py @@ -0,0 +1,37 @@ +"""Tests for the HomematicIP Cloud event.""" + +from homematicip.base.channel_event import ChannelEvent + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, get_and_check_entity_basics + + +async def test_door_bell_event( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test of door bell event of HmIP-DSD-PCB.""" + entity_id = "event.dsdpcb_klingel_doorbell" + entity_name = "dsdpcb_klingel doorbell" + device_model = "HmIP-DSD-PCB" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["dsdpcb_klingel"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + ch = hmip_device.functionalChannels[1] + channel_event = ChannelEvent( + channelEventType="DOOR_BELL_SENSOR_EVENT", channelIndex=1, deviceId=ch.device.id + ) + + assert ha_state.state == STATE_UNKNOWN + + ch.fire_channel_event(channel_event) + + ha_state = hass.states.get(entity_id) + assert ha_state.state != STATE_UNKNOWN diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index dfd92577a04221..b540ebac91a8b7 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,8 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import NotFoundError -from homewizard_energy.v1.models import Data, Device, State, System +from homewizard_energy.models import CombinedModels, Device, Measurement, State, System import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -27,7 +26,7 @@ def mock_homewizardenergy( """Return a mock bridge.""" with ( patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergyV1", + "homeassistant.components.homewizard.HomeWizardEnergyV1", autospec=True, ) as homewizard, patch( @@ -37,26 +36,31 @@ def mock_homewizardenergy( ): client = homewizard.return_value - client.device.return_value = Device.from_dict( - load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) - ) - client.data.return_value = Data.from_dict( - load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + ), + state=( + State.from_dict( + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() + else None + ), ) - if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): - client.state.return_value = State.from_dict( - load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) - ) - else: - client.state.side_effect = NotFoundError - - if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): - client.system.return_value = System.from_dict( - load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) - ) - else: - client.system.side_effect = NotFoundError + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device yield client diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index cb5e7ef1f43d34..b8cf98d921105e 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -2,82 +2,83 @@ # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'HWE-KWH1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH1', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'HWE-KWH1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -91,82 +92,83 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH3', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'HWE-KWH3', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -180,133 +182,119 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.19', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi P1 Meter', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'active_voltage_v': None, 'any_power_fail_count': 4, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': 123.0, + 'current_a': None, + 'current_l1_a': -4.0, + 'current_l2_a': 2.0, + 'current_l3_a': 0.0, + 'cycles': None, + 'energy_export_kwh': 13086.777, + 'energy_export_t1_kwh': 4321.333, + 'energy_export_t2_kwh': 8765.444, + 'energy_export_t3_kwh': 8765.444, + 'energy_export_t4_kwh': 8765.444, + 'energy_import_kwh': 13779.338, + 'energy_import_t1_kwh': 10830.511, + 'energy_import_t2_kwh': 2948.827, + 'energy_import_t3_kwh': 2948.827, + 'energy_import_t4_kwh': 2948.827, 'external_devices': dict({ 'gas_meter_G001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'gas_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 111.111, }), 'heat_meter_H001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'heat_meter', 'unique_id': '**REDACTED**', 'unit': 'GJ', 'value': 444.444, }), 'inlet_heat_meter_IH001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'inlet_heat_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 555.555, }), 'warm_water_meter_WW001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'warm_water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 333.333, }), 'water_meter_W001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 222.222, }), }), - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', + 'frequency_hz': 50.0, 'long_power_fail_count': 5, 'meter_model': 'ISKRA 2M550T-101', 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': 8765.444, - 'total_energy_export_t4_kwh': 8765.444, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': 2948.827, - 'total_energy_import_t4_kwh': 2948.827, - 'total_gas_m3': 1122.333, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -123.0, + 'power_l2_w': 456.0, + 'power_l3_w': 123.456, + 'power_w': -123.0, + 'protocol_version': 50, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': 2, + 'timestamp': None, 'total_liter_m3': 1234.567, - 'unique_meter_id': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'voltage_l1_v': 230.111, + 'voltage_l2_v': 230.222, + 'voltage_l3_v': 230.333, 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, 'voltage_sag_l3_count': 3, 'voltage_swell_l1_count': 4, 'voltage_swell_l2_count': 5, 'voltage_swell_l3_count': 6, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.19', - 'product': dict({ - 'description': 'The HomeWizard P1 Meter gives you detailed insight in your electricity-, gas consumption and solar surplus.', - 'model': 'HWE-P1', - 'name': 'Wi-Fi P1 Meter', - 'url': 'https://www.homewizard.com/p1-meter/', - }), - 'product_name': 'P1 meter', - 'product_type': 'HWE-P1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -320,86 +308,87 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 1457.277, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 1457.277, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 0.0, + 'energy_export_t1_kwh': 0.0, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 63.651, + 'energy_import_t1_kwh': 63.651, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0, - 'total_energy_export_t1_kwh': 0, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 63.651, - 'total_energy_import_t1_kwh': 63.651, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 1457.277, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 1457.277, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 94, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.03', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -413,86 +402,87 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 666.768, - 'active_current_a': 2.346, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50.005, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.07', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.81688, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 543.312, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 543.312, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': 123.456, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 231.539, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 666.768, + 'average_power_15m_w': None, + 'current_a': 2.346, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 85.951, + 'energy_export_t1_kwh': 85.951, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 30.511, + 'energy_import_t1_kwh': 30.511, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.005, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 85.951, - 'total_energy_export_t1_kwh': 85.951, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 30.511, - 'total_energy_import_t1_kwh': 30.511, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.81688, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 543.312, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 543.312, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': 123.456, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 231.539, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.07', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -506,82 +496,83 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, - 'active_liter_lpm': 0, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': None, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': None, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Watermeter', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': 0.0, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': None, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': None, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': None, - 'total_energy_export_t1_kwh': None, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': None, - 'total_energy_import_t1_kwh': None, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': None, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': 17.014, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 84, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '2.03', - 'product': dict({ - 'description': 'Real-time water consumption insights', - 'model': 'HWE-WTR', - 'name': 'Wi-Fi Watermeter', - 'url': 'https://www.homewizard.com/watermeter/', - }), - 'product_name': 'Watermeter', - 'product_type': 'HWE-WTR', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -595,82 +586,83 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM230-wifi', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'SDM230-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -684,82 +676,83 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM630-wifi', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'SDM630-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index c5de96cbf8f551..31a949ca7bd88c 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -431,7 +431,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_power:device-registry] @@ -1124,7 +1124,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_apparent_power_phase_2:device-registry] @@ -1472,7 +1472,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_current_phase_2:device-registry] @@ -2084,7 +2084,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_power_factor_phase_2:device-registry] @@ -2702,7 +2702,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_reactive_power_phase_2:device-registry] @@ -3476,7 +3476,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] @@ -3563,7 +3563,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] @@ -3650,7 +3650,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] @@ -4689,7 +4689,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] @@ -4945,7 +4945,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] @@ -5117,7 +5117,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] @@ -5207,7 +5207,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] @@ -7236,7 +7236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:device-registry] @@ -7323,7 +7323,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:device-registry] @@ -7410,7 +7410,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:device-registry] @@ -8449,7 +8449,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:device-registry] @@ -8705,7 +8705,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:device-registry] @@ -8877,7 +8877,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:device-registry] @@ -8967,7 +8967,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:device-registry] @@ -10909,7 +10909,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:device-registry] @@ -10996,7 +10996,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:device-registry] @@ -11083,7 +11083,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:device-registry] @@ -11170,7 +11170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:device-registry] @@ -12127,7 +12127,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] @@ -13664,7 +13664,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_import:device-registry] @@ -15316,7 +15316,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_wi_fi_ssid:device-registry] @@ -15919,7 +15919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[SDM230-entity_ids5][sensor.device_power:device-registry] @@ -16612,7 +16612,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_apparent_power_phase_2:device-registry] @@ -16960,7 +16960,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_current_phase_2:device-registry] @@ -17572,7 +17572,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_power_factor_phase_2:device-registry] @@ -18190,7 +18190,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_reactive_power_phase_2:device-registry] diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c2ef87970f3632..8f6af16068dbc9 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -45,7 +45,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -81,7 +81,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -94,7 +94,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -127,7 +127,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -163,7 +163,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -177,7 +177,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -210,7 +210,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -246,7 +246,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -259,7 +259,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -292,7 +292,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -328,7 +328,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -341,7 +341,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -374,7 +374,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -410,7 +410,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -424,7 +424,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -457,7 +457,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -493,7 +493,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -506,7 +506,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -539,7 +539,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -575,7 +575,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -588,7 +588,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,7 +621,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -657,7 +657,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -670,7 +670,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -703,7 +703,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -739,7 +739,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -752,7 +752,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -785,7 +785,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -821,7 +821,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -834,7 +834,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -867,7 +867,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 984fda8e7a4271..b2ae7bd45e0133 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -8,11 +8,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -54,7 +55,7 @@ async def test_discovery_flow_works( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -100,7 +101,7 @@ async def test_discovery_flow_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -137,7 +138,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -181,7 +182,7 @@ async def test_discovery_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -216,7 +217,7 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -242,7 +243,7 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -274,7 +275,7 @@ async def test_dhcp_discovery_updates_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -304,7 +305,7 @@ async def test_dhcp_discovery_updates_entry_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -326,7 +327,7 @@ async def test_dhcp_discovery_ignores_unknown( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="127.0.0.1", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -350,7 +351,7 @@ async def test_discovery_flow_updates_new_ip( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.0.0.127"), ip_addresses=[ip_address("1.0.0.127")], port=80, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index a01f075ee61c69..ed4bad8b2e87ce 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -25,7 +25,7 @@ async def test_load_unload( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.device.mock_calls) == 1 + assert len(mock_homewizardenergy.combined.mock_calls) == 1 await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -39,7 +39,7 @@ async def test_load_failed_host_unavailable( mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - mock_homewizardenergy.device.side_effect = TimeoutError() + mock_homewizardenergy.combined.side_effect = TimeoutError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -53,7 +53,7 @@ async def test_load_detect_api_disabled( mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -115,7 +115,7 @@ async def test_disablederror_reloads_integration( assert len(flows) == 0 # Simulate DisabledError and wait for next update - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 623ba018dee661..b668043608c080 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion @@ -44,7 +45,9 @@ async def test_number_entities( # Test unknown handling assert state.state == "100" - mock_homewizardenergy.state.return_value.brightness = None + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, measurement=Measurement(), system=System(), state=State() + ) async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -53,7 +56,7 @@ async def test_number_entities( assert state.state == STATE_UNKNOWN # Test service methods - assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + assert len(mock_homewizardenergy.state.mock_calls) == 0 await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -64,10 +67,10 @@ async def test_number_entities( blocking=True, ) - assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=129) + assert len(mock_homewizardenergy.system.mock_calls) == 1 + mock_homewizardenergy.system.assert_called_with(status_led_brightness_pct=50) - mock_homewizardenergy.state_set.side_effect = RequestError + mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, match=r"^An error occurred while communicating with HomeWizard device$", @@ -82,7 +85,7 @@ async def test_number_entities( blocking=True, ) - mock_homewizardenergy.state_set.side_effect = DisabledError + mock_homewizardenergy.system.side_effect = DisabledError with pytest.raises( HomeAssistantError, match=r"^The local API is disabled$", diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 60077c2cdf9600..128a3de2ebf4d5 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError -from homewizard_energy.v1.models import Data import pytest from syrupy.assertion import SnapshotAssertion @@ -456,7 +455,7 @@ async def test_sensors_unreachable( assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" - mock_homewizardenergy.data.side_effect = exception + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -464,15 +463,17 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize("exception", [RequestError]) async def test_external_sensors_unreachable( hass: HomeAssistant, mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test external device sensor handles API unreachable.""" assert (state := hass.states.get("sensor.gas_meter_gas")) assert state.state == "111.111" - mock_homewizardenergy.data.return_value = Data.from_dict({}) + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index d9f1ac26b4f4b4..ccf99ee27fafb4 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -86,17 +86,17 @@ async def test_entities_not_created_for_device( @pytest.mark.parametrize( ("device_fixture", "entity_id", "method", "parameter"), [ - ("HWE-SKT-11", "switch.device", "state_set", "power_on"), - ("HWE-SKT-11", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-11", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-SKT-21", "switch.device", "state_set", "power_on"), - ("HWE-SKT-21", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-21", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-WTR", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-SKT-11", "switch.device", "state", "power_on"), + ("HWE-SKT-11", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-11", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-SKT-21", "switch.device", "state", "power_on"), + ("HWE-SKT-21", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-21", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-WTR", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH1", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system", "cloud_enabled"), ], ) async def test_switch_entities( @@ -200,9 +200,9 @@ async def test_switch_entities( @pytest.mark.parametrize( ("entity_id", "method"), [ - ("switch.device", "state"), - ("switch.device_switch_lock", "state"), - ("switch.device_cloud_connection", "system"), + ("switch.device", "combined"), + ("switch.device_switch_lock", "combined"), + ("switch.device_cloud_connection", "combined"), ], ) async def test_switch_unreachable( diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index f26064b335a20e..1e9958acb3ffe8 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -12,6 +12,7 @@ ]), 'friendly_name': 'device1', 'humidity': None, + 'hvac_action': , 'hvac_modes': list([ , , diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 57cdfaa9a23eee..7411a40e74a1d8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1200,6 +1200,7 @@ async def test_unique_id( entity_registry: er.EntityRegistry, ) -> None: """Test unique id convert to string.""" + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( Platform.CLIMATE, DOMAIN, diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 052c0031469999..e31e630807e196 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -192,16 +192,16 @@ async def test_cannot_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_active_access_with_access_token_in_header( @@ -256,16 +256,16 @@ async def test_auth_active_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_legacy_support_api_password_cannot_access( diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index a9a147eb17e7b5..f75b0e7f2b0781 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,6 @@ from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN from homeassistant.const import ( CONF_NAME, @@ -25,6 +24,18 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -267,8 +278,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "Mock device", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - ssdp.ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ATTR_UPNP_SERIAL: "00000000", }, { "type": FlowResultType.FORM, @@ -283,8 +294,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "100002", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - # No ssdp.ATTR_UPNP_SERIAL + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ATTR_UPNP_SERIAL }, { "type": FlowResultType.FORM, @@ -322,18 +333,18 @@ async def test_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="upnp:rootdevice", ssdp_location=f"{url}:60957/rootDesc.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", - ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", - ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", **upnp_data, }, ), diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 692bd1405cf059..e4bdda422d1c82 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -9,12 +9,15 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @@ -424,13 +427,13 @@ async def test_bridge_homekit( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -474,13 +477,13 @@ async def test_bridge_homekit_already_configured( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -578,7 +581,7 @@ async def test_bridge_zeroconf( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -614,7 +617,7 @@ async def test_bridge_zeroconf_already_exists( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -639,7 +642,7 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, @@ -687,7 +690,7 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=443, @@ -708,13 +711,13 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index 388e2f68f99038..7b00630f573615 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -166,6 +166,7 @@ async def test_group_entity_migration_with_v1_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id @@ -201,6 +202,7 @@ async def test_group_entity_migration_with_v2_group_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id diff --git a/tests/components/hunterdouglas_powerview/const.py b/tests/components/hunterdouglas_powerview/const.py index 65b03fd5ec20cd..2c122ae10f2eaa 100644 --- a/tests/components/hunterdouglas_powerview/const.py +++ b/tests/components/hunterdouglas_powerview/const.py @@ -3,32 +3,36 @@ from ipaddress import IPv4Address from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) MOCK_MAC = "AA::BB::CC::DD::EE::FF" MOCK_SERIAL = "A1B2C3D4E5G6H7" -HOMEKIT_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 2._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -HOMEKIT_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 3._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -38,7 +42,7 @@ type="mock_type", ) -ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -48,19 +52,19 @@ type="mock_type", ) -DHCP_DISCOVERY_GEN2 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2 = DhcpServiceInfo( hostname="Powerview Generation 2", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN2_NO_NAME = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2_NO_NAME = DhcpServiceInfo( hostname="", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN3 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN3 = DhcpServiceInfo( hostname="Powerview Generation 3", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 42589bb10e05ad..cf159c23baed21 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -5,12 +5,13 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA, MOCK_SERIAL @@ -65,7 +66,7 @@ async def test_form_homekit_and_dhcp_cannot_connect( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo, + discovery_info: DhcpServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -112,7 +113,7 @@ async def test_form_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo, + discovery_info: DhcpServiceInfo | ZeroconfServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -166,10 +167,10 @@ async def test_discovered_by_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, homekit_source: str, - homekit_discovery: zeroconf.ZeroconfServiceInfo, + homekit_discovery: ZeroconfServiceInfo, api_version: int, dhcp_source: str, - dhcp_discovery: dhcp.DhcpServiceInfo, + dhcp_discovery: DhcpServiceInfo, dhcp_api_version: int, ) -> None: """Test we get the form with homekit and abort for dhcp source when we get both.""" @@ -368,6 +369,7 @@ async def test_migrate_entry( version=1, minor_version=1, ) + entry.add_to_hass(hass) # Add entries with int unique_id entity_registry.async_get_or_create( @@ -387,7 +389,6 @@ async def test_migrate_entry( assert entry.version == 1 assert entry.minor_version == 1 - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 0202cec05b971e..49994e4f3aed4e 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -58,6 +58,15 @@ def mock_values(mower_time_zone) -> dict[str, MowerAttributes]: ) +@pytest.fixture(name="values_one_mower") +def mock_values_one_mower(mower_time_zone) -> dict[str, MowerAttributes]: + """Fixture to set correct scope for the token.""" + return mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower1.json", DOMAIN), + mower_time_zone, + ) + + @pytest.fixture def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -119,3 +128,26 @@ async def listen() -> None: return_value=mock, ): yield mock + + +@pytest.fixture +def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: + """Mock a Husqvarna Automower client.""" + + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + mock = AsyncMock(spec=AutomowerSession) + mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) + + with patch( + "homeassistant.components.husqvarna_automower.AutomowerSession", + return_value=mock, + ): + yield mock diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ae688571d2c640..627cd065e79de9 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -227,32 +227,79 @@ async def test_coordinator_automatic_registry_cleanup( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, ) -> None: """Test automatic registry cleanup.""" await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.async_block_till_done() + # Count current entitties and devices current_entites = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) current_devices = len( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) - - values.pop(TEST_MOWER_ID) + # Remove mower 2 and check if it worked + mower2 = values.pop("1234") mock_automower_client.get_status.return_value = values - await hass.config_entries.async_reload(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 37 + == current_entites - 12 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 ) + # Add mower 2 and check if it worked + values["1234"] = mower2 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + + # Remove mower 1 and check if it worked + mower1 = values.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices - 1 + ) + # Add mower 1 and check if it worked + values[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) async def test_add_and_remove_work_area( diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 4109fe0f653147..ac7e6c25b0da77 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -10,7 +10,6 @@ from hyperion import const -from homeassistant.components import ssdp from homeassistant.components.hyperion.const import ( CONF_AUTH_ID, CONF_CREATE_TOKEN, @@ -30,6 +29,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( TEST_AUTH_REQUIRED_RESP, @@ -67,7 +67,7 @@ "error": "Token request timeout or denied", } -TEST_SSDP_SERVICE_INFO = ssdp.SsdpServiceInfo( +TEST_SSDP_SERVICE_INFO = SsdpServiceInfo( ssdp_st="upnp:rootdevice", ssdp_location=f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", ssdp_usn=f"uuid:{TEST_SYSINFO_ID}", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index d4281b9e513400..b86855bd78f365 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -171,11 +171,8 @@ async def test_receiving_message_successfully( assert data["subject"] == "Test subject" assert data["uid"] == "1" assert "Test body" in data["text"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) @@ -581,11 +578,8 @@ async def _sleep_till_event() -> None: assert data["subject"] == "Test subject" assert data["text"] assert data["initial"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) # Simulate an update where no messages are found (needed for pushed coordinator) diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index 6f23ed3ee80634..a10b9b5453240f 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -16,11 +16,11 @@ river="River Name", station_id="123", water_level=SensorData(name="Water Level", value=526.0), - flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), - flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=None), + flood_warning_level=SensorData(name="Flood Warning Level", value=None), water_temperature=SensorData(name="Water Temperature", value=10.8), - flood_alarm=False, - flood_warning=False, + flood_alarm=None, + flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr b/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr deleted file mode 100644 index c5ae6880022f86..00000000000000 --- a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr +++ /dev/null @@ -1,97 +0,0 @@ -# serializer version: 1 -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm', - 'unique_id': '123_flood_alarm', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood alarm', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning', - 'unique_id': '123_flood_warning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood warning', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce22c..a98f60a2b3e48d 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -20,17 +20,17 @@ 'version': 1, }), 'hydrological_data': dict({ - 'flood_alarm': False, + 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', 'unit': None, - 'value': 630.0, + 'value': None, }), - 'flood_warning': False, + 'flood_warning': None, 'flood_warning_level': dict({ 'name': 'Flood Warning Level', 'unit': None, - 'value': 590.0, + 'value': None, }), 'river': 'River Name', 'station': 'Station Name', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 6c69b890842197..c7779f5d850275 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,108 +1,4 @@ # serializer version: 1 -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm_level', - 'unique_id': '123_flood_alarm_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood alarm level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '630.0', - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning_level', - 'unique_id': '123_flood_warning_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood warning level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '590.0', - }) -# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/imgw_pib/test_binary_sensor.py b/tests/components/imgw_pib/test_binary_sensor.py deleted file mode 100644 index 185d4b18575939..00000000000000 --- a/tests/components/imgw_pib/test_binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Test the IMGW-PIB binary sensor platform.""" - -from unittest.mock import AsyncMock, patch - -from freezegun.api import FrozenDateTimeFactory -from imgw_pib import ApiError -from syrupy import SnapshotAssertion - -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - -ENTITY_ID = "binary_sensor.river_name_station_name_flood_alarm" - - -async def test_binary_sensor( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test states of the binary sensor.""" - with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.BINARY_SENSOR]): - await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_availability( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure that we mark the entities unavailable correctly when service is offline.""" - await init_integration(hass, mock_config_entry) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" - - mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNAVAILABLE - - mock_imgw_pib_client.get_hydrological_data.side_effect = None - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index e1b7cda7c88c97..e352c643676658 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -4,9 +4,11 @@ from imgw_pib import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.components.imgw_pib.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import init_integration @@ -43,3 +45,27 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_remove_binary_sensor_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a binary_sensor entity.""" + entity_id = "binary_sensor.river_name_station_name_flood_alarm" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + BINARY_SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index 276c021fad590f..a1920f38006acd 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -7,7 +7,8 @@ import pytest from syrupy import SnapshotAssertion -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -65,3 +66,27 @@ async def test_availability( assert state assert state.state != STATE_UNAVAILABLE assert state.state == "526.0" + + +async def test_remove_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing entity.""" + entity_id = "sensor.river_name_station_name_flood_alarm_level" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm_level", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 2df4be2ba7d2d5..4536c64349c3f7 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -664,6 +664,6 @@ async def test_provision_fails_invalid_data( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_improv_data" assert ( - "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + "Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'" in caplog.text ) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index b00e3a638c8047..3829c42d07f41a 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -8,7 +8,6 @@ import pytest from homeassistant.components.incomfort.const import DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -20,7 +19,7 @@ } MOCK_HEATER_STATUS = { - "display_code": DisplayCode(126), + "display_code": DisplayCode.STANDBY, "display_text": "standby", "fault_code": None, "is_burning": False, @@ -36,6 +35,23 @@ "rfstatus_cntr": 0, } +MOCK_HEATER_STATUS_HEATING = { + "display_code": DisplayCode.OPENTHERM, + "display_text": "opentherm", + "fault_code": None, + "is_burning": True, + "is_failed": False, + "is_pumping": True, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "c0ffeec0ffee", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, +} + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -53,12 +69,22 @@ def mock_entry_data() -> dict[str, Any]: return MOCK_CONFIG +@pytest.fixture +def mock_entry_options() -> dict[str, Any] | None: + """Mock config entry options for fixture.""" + return None + + @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_entry_data: dict[str, Any] -) -> ConfigEntry: + hass: HomeAssistant, + mock_entry_data: dict[str, Any], + mock_entry_options: dict[str, Any], +) -> MockConfigEntry: """Mock a config entry setup for incomfort integration.""" - entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) + entry = MockConfigEntry( + domain=DOMAIN, data=mock_entry_data, options=mock_entry_options + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index 2f2319b6a4421d..fe0d8edd0f001d 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -10,7 +10,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -57,7 +57,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -105,7 +105,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -152,7 +152,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -199,7 +199,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -246,7 +246,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -294,7 +294,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -341,7 +341,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -388,7 +388,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -435,7 +435,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -483,7 +483,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -530,7 +530,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -577,7 +577,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -624,7 +624,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -672,7 +672,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -719,7 +719,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -766,7 +766,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -813,7 +813,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -861,7 +861,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -908,7 +908,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 17adcbb3bab30e..e0e8b9562dd5e5 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[legacy-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -16,7 +16,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -38,7 +38,73 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[legacy-override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -65,7 +131,7 @@ 'state': 'heat', }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[modern-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -82,7 +148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -104,7 +170,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[modern-override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -116,7 +182,73 @@ 'max_temp': 30.0, 'min_temp': 5.0, 'status': dict({ - 'override': 18.0, + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, 'room_temp': 21.42, 'setpoint': 18.0, }), diff --git a/tests/components/incomfort/snapshots/test_diagnostics.ambr b/tests/components/incomfort/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..e7c99f37acd27a --- /dev/null +++ b/tests/components/incomfort/snapshots/test_diagnostics.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config': dict({ + 'host': '192.168.1.12', + 'password': '**REDACTED**', + 'username': 'admin', + }), + 'gateway': dict({ + 'heater_0': dict({ + 'display_code': 126, + 'display_text': 'standby', + 'fault_code': None, + 'heater_temp': 35.34, + 'is_burning': False, + 'is_failed': False, + 'is_pumping': False, + 'is_tapping': False, + 'nodenr': 249, + 'pressure': 1.86, + 'rf_message_rssi': 30, + 'rfstatus_cntr': 0, + 'rooms': dict({ + '0': dict({ + 'override': 18.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + }), + 'serial_no': 'c0ffeec0ffee', + 'tap_temp': 30.21, + }), + }), + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index 8c9ea60f455781..a69a64d964e5c9 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_pressure', 'has_entity_name': True, 'hidden_by': None, @@ -63,7 +63,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_tap_temperature', 'has_entity_name': True, 'hidden_by': None, @@ -115,7 +115,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_temperature', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index 06b0d0c1e52da3..d2cd955a9fc4a2 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'water_heater', - 'entity_category': None, + 'entity_category': , 'entity_id': 'water_heater.boiler', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py index c724cf4b7b249c..e90cc3ac391a6f 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -17,6 +17,7 @@ @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platform( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -45,6 +46,7 @@ async def test_setup_platform( ids=["is_failed", "is_pumping", "is_burning", "is_tapping"], ) @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_binary_sensors_alt( hass: HomeAssistant, mock_incomfort: MagicMock, diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index ae4c1cf31f7984..dbcf14e3bd792f 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -1,15 +1,19 @@ """Climate sensor tests for Intergas InComfort integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from syrupy import SnapshotAssertion +from homeassistant.components import climate +from homeassistant.components.incomfort.coordinator import InComfortData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS, MOCK_HEATER_STATUS_HEATING + from tests.common import snapshot_platform @@ -17,10 +21,15 @@ @pytest.mark.parametrize( "mock_room_status", [ - {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, + {"room_temp": 21.42, "setpoint": 18.0, "override": 19.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, ], - ids=["new_thermostat", "legacy_thermostat"], + ids=["override", "zero_override"], +) +@pytest.mark.parametrize( + "mock_entry_options", + [None, {"legacy_setpoint_status": True}], + ids=["modern", "legacy"], ) async def test_setup_platform( hass: HomeAssistant, @@ -31,8 +40,54 @@ async def test_setup_platform( ) -> None: """Test the incomfort entities are set up correctly. - Legacy thermostats report 0.0 as override if no override is set, - but new thermostat sync the override with the actual setpoint instead. + Thermostats report 0.0 as override if no override is set + or when the setpoint has been changed manually, + Some older thermostats do not reset the override setpoint has been changed manually. """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "hvac_action"), + [ + (MOCK_HEATER_STATUS.copy(), climate.HVACAction.IDLE), + (MOCK_HEATER_STATUS_HEATING.copy(), climate.HVACAction.HEATING), + ], + ids=["idle", "heating"], +) +async def test_hvac_state( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: ConfigEntry, + hvac_action: climate.HVACAction, +) -> None: + """Test the HVAC state of the thermostat.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["hvac_action"] is hvac_action + + +async def test_target_temp( + hass: HomeAssistant, mock_incomfort: MagicMock, mock_config_entry: ConfigEntry +) -> None: + """Test changing the target temperature.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + + incomfort_data: InComfortData = mock_config_entry.runtime_data.incomfort_data + + with patch.object( + incomfort_data.heaters[0].rooms[0], "set_override", AsyncMock() + ) as mock_set_override: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: "climate.thermostat_1", + ATTR_TEMPERATURE: 19.0, + }, + ) + mock_set_override.assert_called_once_with(19.0) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 287fd85715facf..9ab5a672d61df7 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the Intergas InComfort config flow.""" -from unittest.mock import AsyncMock, MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList @@ -38,7 +39,9 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 -async def test_entry_already_configured(hass: HomeAssistant) -> None: +async def test_entry_already_configured( + hass: HomeAssistant, mock_incomfort: MagicMock +) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -113,3 +116,143 @@ async def test_form_validation( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert "errors" not in result + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "incorrect-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "wrong-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("user_input", "legacy_setpoint_status"), + [ + ({}, False), + ({"legacy_setpoint_status": False}, False), + ({"legacy_setpoint_status": True}, True), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_incomfort: MagicMock, + user_input: dict[str, Any], + legacy_setpoint_status: bool, +) -> None: + """Test options flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert restart_mock.call_count == 1 + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status} + assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status diff --git a/tests/components/incomfort/test_diagnostics.py b/tests/components/incomfort/test_diagnostics.py new file mode 100644 index 00000000000000..02493681705c9e --- /dev/null +++ b/tests/components/incomfort/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test diagnostics for the Intergas InComfort integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, SnapshotAssertion +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the incomfort integration diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + snapshot.assert_match( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + ) diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 0390a47a616b67..f603c3ce27b68b 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,13 +1,14 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp import ClientResponseError +from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory from incomfortclient import IncomfortError import pytest +from homeassistant.components.incomfort import InvalidHeaterList from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -17,6 +18,7 @@ from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -28,6 +30,7 @@ async def test_setup_platforms( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -59,11 +62,23 @@ async def test_coordinator_updates( assert state.state == "1.84" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ IncomfortError(ClientResponseError(None, None, status=401)), - IncomfortError(ClientResponseError(None, None, status=500)), + IncomfortError( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ) + ), IncomfortError(ValueError("some_error")), TimeoutError, ], @@ -91,3 +106,54 @@ async def test_coordinator_update_fails( state = hass.states.get("sensor.boiler_pressure") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("exc", "config_entry_state"), + [ + ( + IncomfortError(ClientResponseError(None, None, status=401)), + ConfigEntryState.SETUP_ERROR, + ), + ( + IncomfortError(ClientResponseError(None, None, status=404)), + ConfigEntryState.SETUP_ERROR, + ), + (InvalidHeaterList, ConfigEntryState.SETUP_RETRY), + ( + IncomfortError( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ) + ), + ConfigEntryState.SETUP_RETRY, + ), + (IncomfortError(ValueError("some_error")), ConfigEntryState.SETUP_RETRY), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_entry_setup_fails( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: ConfigEntry, + exc: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the incomfort coordinator entry setup fails.""" + with patch( + "homeassistant.components.incomfort.async_connect_gateway", + AsyncMock(side_effect=exc), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("sensor.boiler_pressure") + assert state is None + assert mock_config_entry.state is config_entry_state diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index d01fd9b403edb3..df0db39a56cf38 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -12,6 +13,7 @@ from tests.common import snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.SENSOR]) async def test_setup_platform( hass: HomeAssistant, diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 31d38a603f16d9..33e71be6dc2ffa 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,7 +8,6 @@ from voluptuous_serialize import convert from homeassistant import config_entries -from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, @@ -20,6 +19,8 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import ( MOCK_DEVICE, @@ -209,7 +210,7 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: """Test the discovery of the Hub via DHCP.""" - discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") + discovery_info = DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info ) @@ -270,7 +271,7 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", @@ -302,7 +303,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index f1465c4dcd4bfc..96d0fa17e635b8 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -5,11 +5,11 @@ from intellifire4py.exceptions import LoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 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 @@ -166,7 +166,7 @@ async def test_dhcp_discovery_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Test", @@ -196,7 +196,7 @@ async def test_dhcp_discovery_non_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Evil", diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ab5998c922f20f..031ff3c31d45b3 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -6,6 +6,7 @@ from pyipma.observation import Observation from pyipma.rcm import RCM from pyipma.uv import UV +from pyipma.warnings import Warning from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -20,6 +21,20 @@ class MockLocation: """Mock Location from pyipma.""" + async def warnings(self, api): + """Mock Warnings.""" + return [ + Warning( + text="Na costa Sul, ondas de sueste com 2 a 2,5 metros, em especial " + "no barlavento.", + awarenessTypeName="Agitação Marítima", + idAreaAviso="FAR", + startTime=datetime(2024, 12, 26, 12, 24), + awarenessLevelID="yellow", + endTime=datetime(2024, 12, 28, 6, 0), + ) + ] + async def fire_risk(self, api): """Mock Fire Risk.""" return RCM("some place", 3, (0, 0)) diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index adff8206add267..455a85002d3169 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -35,3 +35,19 @@ async def test_ipma_uv_index_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hometown_uv_index") assert state.state == "6" + + +async def test_ipma_warning_create_sensors(hass: HomeAssistant) -> None: + """Test creation of warning sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_weather_alert") + + assert state.state == "yellow" + + assert state.attributes["awarenessTypeName"] == "Agitação Marítima" diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index ca374bd7e5ee2e..89b54e22bbb4ca 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ATTR_HOSTNAME = "hostname" ATTR_PROPERTIES = "properties" @@ -30,7 +30,7 @@ CONF_BASE_PATH: BASE_PATH, } -MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPP_SERVICE_INFO = ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -40,7 +40,7 @@ properties={"rp": ZEROCONF_RP}, ) -MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPPS_SERVICE_INFO = ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..f8db126225456a --- /dev/null +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'address': 'c0:ff:ee:c0:ff:ee', + }), + 'device_info': dict({ + '__type': "", + 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + }), + 'live_data': dict({ + '__type': "", + 'repr': 'LiveDataResponse(live_temp=298, setpoint_temp=300, dc_voltage=20.6, handle_temp=36.3, pwm_level=41, power_src=, tip_resistance=6.2, uptime=1671, movement_time=10000, max_tip_temp_ability=460, tip_voltage=2212, hall_sensor=0, operating_mode=, estimated_power=24.8)', + }), + 'settings_data': dict({ + }), + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 24663cc4b0fa56..fc4fe96d746370 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -620,10 +620,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 12, + 'max': 120, 'min': 0, 'mode': , - 'step': 0.1, + 'step': 5, }), 'config_entry_id': , 'device_class': None, @@ -656,10 +656,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Pinecil Power limit', - 'max': 12, + 'max': 120, 'min': 0, 'mode': , - 'step': 0.1, + 'step': 5, 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index ce6045c1243eff..e3989fbf8639bd 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -237,6 +237,61 @@ 'state': 'right_handed', }) # --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + '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': 'Power Delivery 3.1 EPR', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', + 'options': list([ + 'off', + 'on', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_state[select.pinecil_power_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 9ab5d47eec85fd..0eb8e81fb4fe8b 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -313,6 +313,15 @@ 'sleeping', 'settings', 'debug', + 'soldering_profile', + 'temperature_adjust', + 'usb_pd_debug', + 'thermal_runaway', + 'startup_logo', + 'cjc_calibration', + 'startup_warnings', + 'initialisation_done', + 'hibernating', ]), }), 'config_entry_id': , @@ -354,6 +363,15 @@ 'sleeping', 'settings', 'debug', + 'soldering_profile', + 'temperature_adjust', + 'usb_pd_debug', + 'thermal_runaway', + 'startup_logo', + 'cjc_calibration', + 'startup_warnings', + 'initialisation_done', + 'hibernating', ]), }), 'context': , diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index 0c4f5071a483f1..f13cdcfe66664e 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -275,52 +275,6 @@ 'state': 'on', }) # --- -# name: test_switch_platform[switch.pinecil_power_delivery_3_1_epr-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.pinecil_power_delivery_3_1_epr', - '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': 'Power Delivery 3.1 EPR', - 'platform': 'iron_os', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_switch_platform[switch.pinecil_power_delivery_3_1_epr-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', - }), - 'context': , - 'entity_id': 'switch.pinecil_power_delivery_3_1_epr', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_switch_platform[switch.pinecil_swap_buttons-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/test_diagnostics.py b/tests/components/iron_os/test_diagnostics.py new file mode 100644 index 00000000000000..05f627e6bc63f2 --- /dev/null +++ b/tests/components/iron_os/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for IronOS diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 088b66feb6475e..bdec922a88ccd7 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -126,7 +126,7 @@ async def test_state( 2.0, 2.0, ), - ("number.pinecil_power_limit", CharSetting.POWER_LIMIT, 12.0, 12.0), + ("number.pinecil_power_limit", CharSetting.POWER_LIMIT, 120, 120), ("number.pinecil_quick_charge_voltage", CharSetting.QC_IDEAL_VOLTAGE, 9.0, 9.0), ( "number.pinecil_short_press_temperature_step", diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py index cfd4d8ecbb1b50..8cc848dd4cb4d0 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ ScreenOrientationMode, ScrollSpeed, TempUnit, + USBPDMode, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -105,6 +106,11 @@ async def test_state( "loop", (CharSetting.LOGO_DURATION, LogoDuration.LOOP), ), + ( + "select.pinecil_power_delivery_3_1_epr", + "on", + (CharSetting.USB_PD_MODE, USBPDMode.ON), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py index 4f964133d0a98d..d52c3fd333bbdf 100644 --- a/tests/components/iron_os/test_switch.py +++ b/tests/components/iron_os/test_switch.py @@ -66,7 +66,6 @@ async def test_switch_platform( ("switch.pinecil_detailed_idle_screen", CharSetting.IDLE_SCREEN_DETAILS), ("switch.pinecil_detailed_solder_screen", CharSetting.SOLDER_SCREEN_DETAILS), ("switch.pinecil_invert_screen", CharSetting.DISPLAY_INVERT), - ("switch.pinecil_power_delivery_3_1_epr", CharSetting.USB_PD_MODE), ("switch.pinecil_swap_buttons", CharSetting.INVERT_BUTTONS), ], ) diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index 21877f686dfd6e..aa4f71037c4b63 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -41,7 +41,7 @@ async def test_statistics_import( # Test that consumption statistics for 2 months have been added for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, @@ -70,7 +70,7 @@ async def test_statistics_import( await async_wait_recording_done(hass) for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 2bc1fff222ffc5..3a688f450d05f0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.isy994.const import ( CONF_TLS_VER, DOMAIN, @@ -18,6 +17,12 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -255,13 +260,13 @@ async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -274,13 +279,13 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -322,13 +327,13 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -353,13 +358,13 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -386,13 +391,13 @@ async def test_form_ssdp_existing_entry_with_alternate_port( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -417,13 +422,13 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -440,7 +445,7 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -476,7 +481,7 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="polisy", macaddress=MOCK_POLISY_MAC, @@ -516,7 +521,7 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="eisy", macaddress=MOCK_MAC, @@ -564,7 +569,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -594,7 +599,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -620,7 +625,7 @@ async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py index 8182cb7374357a..6700218f8d4e70 100644 --- a/tests/components/kaleidescape/__init__.py +++ b/tests/components/kaleidescape/__init__.py @@ -1,13 +1,16 @@ """Tests for Kaleidescape integration.""" -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) MOCK_HOST = "127.0.0.1" MOCK_SERIAL = "123456" MOCK_NAME = "Theater" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}", diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index 8ca91d0038609b..dc0c89e8ea6934 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -1,6 +1,5 @@ """Tests for the Keenetic NDMS2 component.""" -from homeassistant.components import ssdp from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import ( CONF_HOST, @@ -9,6 +8,11 @@ CONF_SCAN_INTERVAL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_NAME = "Keenetic Ultra 2030" MOCK_IP = "0.0.0.0" @@ -30,12 +34,12 @@ const.CONF_INTERFACES: ["Home", "VPS0"], } -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, + ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, }, ) diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 18bacc3a32cbc4..7ddcdf38ed6ccd 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -8,11 +8,15 @@ import pytest from homeassistant import config_entries -from homeassistant.components import keenetic_ndms2 as keenetic, ssdp +from homeassistant.components import keenetic_ndms2 as keenetic from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO @@ -200,7 +204,7 @@ async def test_ssdp_ignored(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=keenetic.DOMAIN, source=config_entries.SOURCE_IGNORE, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -222,7 +226,7 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -247,7 +251,7 @@ async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp.pop(ssdp.ATTR_UPNP_UDN) + discovery_info.upnp.pop(ATTR_UPNP_UDN) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -264,7 +268,7 @@ async def test_ssdp_reject_non_keenetic(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" + discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index ff3b28617d335c..ad443fa3154f6a 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -5,6 +5,8 @@ from homeassistant.components.kira import remote as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} @@ -28,6 +30,8 @@ def test_service_call(hass: HomeAssistant) -> None: kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 remote = DEVICES[0] + remote.hass = hass + remote.platform = MockEntityPlatform(hass) assert remote.name == "kira" diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index fe0fc95a918552..3bd46f18765c1d 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -5,6 +5,8 @@ from homeassistant.components.kira import sensor as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} @@ -29,6 +31,8 @@ def test_kira_sensor_callback( kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 sensor = DEVICES[0] + sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) assert sensor.name == "kira" diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index c0ec1dd9b9a18b..4e50836bb79366 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -174,12 +174,12 @@ async def assert_telegram( ) telegram = self._outgoing_telegrams.pop(0) - assert isinstance( - telegram.payload, apci_type - ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" - assert ( - telegram.destination_address == _expected_ga - ), f"Group address mismatch in {telegram} - Expected: {group_address}" + assert isinstance(telegram.payload, apci_type), ( + f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + ) + assert telegram.destination_address == _expected_ga, ( + f"Group address mismatch in {telegram} - Expected: {group_address}" + ) if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore[attr-defined] @@ -335,7 +335,7 @@ async def create_ui_entity( hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], ) -> KnxEntityGenerator: - """Return a helper to create a KNX entities via WS. + """Return a helper to create KNX entities via WS. The KNX integration must be set up before using the helper. """ diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 48cc46ef1eeab7..75cd5d1eb21462 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -1,7 +1,9 @@ """Test KNX init.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from xknx.io import ( DEFAULT_MCAST_GRP, @@ -11,7 +13,10 @@ SecureConfig, ) -from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA +from homeassistant.components.knx.config_flow import ( + DEFAULT_ENTRY_DATA, + DEFAULT_ROUTING_IA, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE, @@ -40,12 +45,13 @@ KNXConfigEntryData, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -262,6 +268,79 @@ async def test_init_connection_handling( ) +async def _init_switch_and_wait_for_first_state_updater_run( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, + config_entry_data: KNXConfigEntryData, +) -> None: + """Return a config entry with default data.""" + config_entry = MockConfigEntry( + title="KNX", domain=KNX_DOMAIN, data=config_entry_data + ) + knx.mock_config_entry = config_entry + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.SWITCH, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, # True uses xknx default state updater + "invert": False, + }, + ) + # created entity sends read-request to KNX bus on connection + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + await knx.assert_no_telegram() + + freezer.tick(timedelta(minutes=1)) # 60 minutes passed + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +async def test_default_state_updater_enabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=True, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + +async def test_default_state_updater_disabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=False, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_no_telegram() + + async def test_async_remove_entry( hass: HomeAssistant, knx: KNXTestKit, @@ -282,7 +361,7 @@ async def test_async_remove_entry( patch("pathlib.Path.rmdir") as rmdir_mock, ): assert await hass.config_entries.async_remove(config_entry.entry_id) - assert unlink_mock.call_count == 3 + assert unlink_mock.call_count == 4 rmdir_mock.assert_called_once() assert hass.config_entries.async_entries() == [] diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index e56ba03b7e5f3c..cc8acbaebf6069 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = { "host": "1.1.1.1", @@ -17,7 +17,7 @@ TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, @@ -28,7 +28,7 @@ ) -TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY_WO_UUID = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 5865616c5449c8..c9fa70de2564d5 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -5,10 +5,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import konnected, ssdp +from homeassistant.components import konnected from homeassistant.components.konnected import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -116,7 +117,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -141,7 +142,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -160,7 +161,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -175,7 +176,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -193,7 +194,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -217,7 +218,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -343,7 +344,7 @@ async def test_import_ssdp_host_user_finish(hass: HomeAssistant, mock_panel) -> ssdp_result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -390,7 +391,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -470,7 +471,7 @@ async def test_ssdp_host_update(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.1.1.1:1234/Device.xml", diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index e25aab39012566..02ade8f2b9c885 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -8,7 +8,6 @@ from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN from homeassistant.config_entries import ( @@ -28,6 +27,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000000..0465dd4dd3a7a4 --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 8b8f98b5806b3d..d8f21424216ea9 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -24,7 +24,15 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, 'on': None, diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index 04efeaac87f6c5..cc8c1379fe079a 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -52,6 +52,7 @@ async def test_button_app_next( assert device_entry.model_id == "LM 37X8" assert device_entry.name == "Frenck's LaMetric" assert device_entry.sw_version == "2.2.2" + assert device_entry.serial_number == "SA110405124500W00BS9" assert device_entry.hw_version is None await hass.services.async_call( diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index ccbbe005639d6d..c0fb98f190811e 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -13,18 +13,18 @@ ) import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lametric.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2779..6e052603c24adc 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -62,6 +62,7 @@ async def test_brightness( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( @@ -183,3 +184,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 6b3fa291e9c939..e4b9870f52b700 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -55,6 +55,7 @@ async def test_brightness_mode( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 8dff11fb4501ca..08b289e2425e77 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -48,4 +48,5 @@ async def test_wifi_signal( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 367d5605e06ae2..64ebe22e98bd6c 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -57,6 +57,7 @@ async def test_bluetooth( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7ba263bd597485..7bac7cc9e81fc2 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -107,7 +107,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None @@ -124,7 +124,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py index fd840b0c290e36..0b120cd6e232f9 100644 --- a/tests/components/lektrico/conftest.py +++ b/tests/components/lektrico/conftest.py @@ -8,13 +8,13 @@ import pytest from homeassistant.components.lektrico.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import ( ATTR_HW_VERSION, ATTR_SERIAL_NUMBER, CONF_HOST, CONF_TYPE, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py new file mode 100644 index 00000000000000..f7686f815fe311 --- /dev/null +++ b/tests/components/letpot/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the LetPot integration.""" + +from letpot.models import AuthenticationInfo + +AUTHENTICATION = AuthenticationInfo( + access_token="access_token", + access_token_expires=0, + refresh_token="refresh_token", + refresh_token_expires=0, + user_id="a1b2c3d4e5f6a1b2c3d4e5f6", + email="email@example.com", +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py new file mode 100644 index 00000000000000..4cd7ef442a6bd5 --- /dev/null +++ b/tests/components/letpot/conftest.py @@ -0,0 +1,46 @@ +"""Common fixtures for the LetPot tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.letpot.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=AUTHENTICATION.email, + data={ + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + }, + unique_id=AUTHENTICATION.user_id, + ) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py new file mode 100644 index 00000000000000..0ec1bd95d91bba --- /dev/null +++ b/tests/components/letpot/test_config_flow.py @@ -0,0 +1,317 @@ +"""Test the LetPot config flow.""" + +import dataclasses +from typing import Any +from unittest.mock import AsyncMock, patch + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +def _assert_result_success(result: Any) -> None: + """Assert successful end of flow result, creating an entry.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == AUTHENTICATION.email + assert result["data"] == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert result["result"].unique_id == AUTHENTICATION.user_id + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test full flow with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow with exception during login and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_duplicate( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test flow aborts when trying to add a previously added account.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with success.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow with exception during login and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_reauth_different_user_id_new( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, new user ID updating the existing entry.""" + mock_config_entry.add_to_hass(hass) + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == AUTHENTICATION.user_id + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: "new_user_id", + CONF_EMAIL: AUTHENTICATION.email, + } + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == "new_user_id" + + +async def test_reauth_different_user_id_existing( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, existing user ID aborting.""" + mock_config_entry.add_to_hass(hass) + mock_other = MockConfigEntry( + domain=DOMAIN, title="email2@example.com", data={}, unique_id="other_user_id" + ) + mock_other.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries()) == 2 diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba025..2c58b109e618a8 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,146 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_absolute_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-10-10T13:14:00+00:00', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 02b91b4771b962..e1f1a7ed93d5dc 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the LG Thinq sensor platform.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -15,6 +16,7 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time(datetime(2024, 10, 10, tzinfo=UTC)) async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -23,6 +25,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.time_zone = "UTC" with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index d1a6920f84af50..e2a35bcb1b1dc1 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.config_flow import LifXConfigFlow from homeassistant.components.lifx.const import CONF_SERIAL @@ -16,6 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from . import ( @@ -362,7 +366,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ) @@ -385,7 +389,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -402,7 +406,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -416,19 +420,19 @@ def is_matching(self, other_flow) -> bool: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -476,19 +480,19 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -520,19 +524,19 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index ffe819fa2cb1d2..58843d63f9a3ed 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -25,6 +25,7 @@ SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_SKY, + SERVICE_PAINT_THEME, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -1045,6 +1046,104 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: bulb.set_power.reset_mock() +@pytest.mark.usefixtures("mock_discovery") +async def test_paint_theme_service(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + assert call_dict["value"] in [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ] + assert call_dict["duration"] == 4000 + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TRANSITION: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + hue = round(call_dict["value"][0] / 65535 * 360) + sat = round(call_dict["value"][1] / 65535 * 100) + bri = call_dict["value"][2] >> 8 + kel = call_dict["value"][3] + assert (hue, sat, bri, kel) in [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ] + assert call_dict["duration"] == 6000 + + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: diff --git a/tests/components/light/common.py b/tests/components/light/common.py index b29ac0c7c8922f..77411cd637d931 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -26,7 +26,6 @@ DOMAIN, ColorMode, LightEntity, - LightEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -157,7 +156,7 @@ class MockLight(MockToggleEntity, LightEntity): _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN - supported_features = LightEntityFeature(0) + supported_features = 0 brightness = None color_temp_kelvin = None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 303bf68f68c684..6d0337f37a52c7 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,6 +1,7 @@ """The tests for the Light component.""" from types import ModuleType +from typing import Literal from unittest.mock import MagicMock, mock_open, patch import pytest @@ -137,8 +138,13 @@ async def test_services( ent3.supported_color_modes = [light.ColorMode.HS] ent1.supported_features = light.LightEntityFeature.TRANSITION ent2.supported_features = ( - light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION + light.SUPPORT_COLOR + | light.LightEntityFeature.EFFECT + | light.LightEntityFeature.TRANSITION ) + # Set color modes to none to trigger backwards compatibility in LightEntity + ent2.supported_color_modes = None + ent2.color_mode = None ent3.supported_features = ( light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION ) @@ -254,7 +260,10 @@ async def test_services( } _, data = ent2.last_call("turn_on") - assert data == {light.ATTR_EFFECT: "fun_effect"} + assert data == { + light.ATTR_EFFECT: "fun_effect", + light.ATTR_HS_COLOR: (0, 0), + } _, data = ent3.last_call("turn_on") assert data == {light.ATTR_FLASH: "short", light.ATTR_HS_COLOR: (71.059, 100)} @@ -338,6 +347,8 @@ async def test_services( _, data = ent2.last_call("turn_on") assert data == { + light.ATTR_BRIGHTNESS: 100, + light.ATTR_HS_COLOR: profile.hs_color, light.ATTR_TRANSITION: 1, } @@ -915,12 +926,16 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: setup_test_component_platform(hass, light.DOMAIN, entities) entity0 = entities[0] - entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity0.color_mode = light.ColorMode.BRIGHTNESS + entity0.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity0.supported_color_modes = None + entity0.color_mode = None entity0.brightness = 100 entity1 = entities[1] - entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity1.color_mode = light.ColorMode.BRIGHTNESS + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None entity1.brightness = 50 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -981,8 +996,10 @@ async def test_light_brightness_pct_conversion( setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) entity = mock_light_entities[0] - entity.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity.color_mode = light.ColorMode.BRIGHTNESS + entity.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity.supported_color_modes = None + entity.color_mode = None entity.brightness = 100 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1131,6 +1148,167 @@ async def test_profile_load_optional_hs_color(hass: HomeAssistant) -> None: assert invalid_profile_name not in profiles.data +@pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF]) +async def test_light_backwards_compatibility_supported_color_modes( + hass: HomeAssistant, light_state: Literal["on", "off"] +) -> None: + """Test supported_color_modes if not implemented by the entity.""" + entities = [ + MockLight("Test_0", light_state), + MockLight("Test_1", light_state), + MockLight("Test_2", light_state), + MockLight("Test_3", light_state), + MockLight("Test_4", light_state), + ] + + entity0 = entities[0] + + entity1 = entities[1] + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None + + entity2 = entities[2] + entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP + # Set color modes to none to trigger backwards compatibility in LightEntity + entity2.supported_color_modes = None + entity2.color_mode = None + + entity3 = entities[3] + entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + + entity4 = entities[4] + entity4.supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP + ) + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None + + setup_test_component_platform(hass, light.DOMAIN, entities) + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.ONOFF + + state = hass.states.get(entity1.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity2.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity3.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity4.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.ColorMode.COLOR_TEMP, + light.ColorMode.HS, + ] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + +async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> None: + """Test color_mode if not implemented by the entity.""" + entities = [ + MockLight("Test_0", STATE_ON), + MockLight("Test_1", STATE_ON), + MockLight("Test_2", STATE_ON), + MockLight("Test_3", STATE_ON), + MockLight("Test_4", STATE_ON), + ] + + entity0 = entities[0] + + entity1 = entities[1] + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None + entity1.brightness = 100 + + entity2 = entities[2] + entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP + # Set color modes to none to trigger backwards compatibility in LightEntity + entity2.supported_color_modes = None + entity2.color_mode = None + entity2.color_temp_kelvin = 10000 + + entity3 = entities[3] + entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + entity3.hs_color = (240, 100) + + entity4 = entities[4] + entity4.supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP + ) + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None + entity4.hs_color = (240, 100) + entity4.color_temp_kelvin = 10000 + + setup_test_component_platform(hass, light.DOMAIN, entities) + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] + assert state.attributes["color_mode"] == light.ColorMode.ONOFF + + state = hass.states.get(entity1.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] + assert state.attributes["color_mode"] == light.ColorMode.BRIGHTNESS + + state = hass.states.get(entity2.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP + assert state.attributes["rgb_color"] == (202, 218, 255) + assert state.attributes["hs_color"] == (221.575, 20.9) + assert state.attributes["xy_color"] == (0.278, 0.287) + + state = hass.states.get(entity3.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] + assert state.attributes["color_mode"] == light.ColorMode.HS + + state = hass.states.get(entity4.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.ColorMode.COLOR_TEMP, + light.ColorMode.HS, + ] + # hs color prioritized over color_temp, light should report mode ColorMode.HS + assert state.attributes["color_mode"] == light.ColorMode.HS + + async def test_light_service_call_rgbw(hass: HomeAssistant) -> None: """Test rgbw functionality in service calls.""" entity0 = MockLight("Test_rgbw", STATE_ON) @@ -1186,7 +1364,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_onoff", "supported_color_modes": [light.ColorMode.ONOFF], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, } state = hass.states.get(entity1.entity_id) @@ -1194,7 +1372,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_brightness", "supported_color_modes": [light.ColorMode.BRIGHTNESS], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, } @@ -1203,7 +1381,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_ct", "supported_color_modes": [light.ColorMode.COLOR_TEMP], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, "color_temp": None, "color_temp_kelvin": None, @@ -1221,7 +1399,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, "rgbw_color": None, "hs_color": None, @@ -1252,7 +1430,7 @@ async def test_light_state_rgbw(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBW, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "hs_color": (240.0, 25.0), "rgb_color": (3, 3, 4), "rgbw_color": (1, 2, 3, 4), @@ -1283,7 +1461,7 @@ async def test_light_state_rgbww(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBWW, "friendly_name": "Test_rgbww", "supported_color_modes": [light.ColorMode.RGBWW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "hs_color": (60.0, 20.0), "rgb_color": (5, 5, 4), "rgbww_color": (1, 2, 3, 4, 5), @@ -1299,6 +1477,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), + MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), MockLight("Test_temperature", STATE_ON), @@ -1322,13 +1501,19 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: } entity4 = entities[4] - entity4.supported_color_modes = {light.ColorMode.RGBW} + entity4.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBWW} + entity5.supported_color_modes = {light.ColorMode.RGBW} entity6 = entities[6] - entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP} + entity6.supported_color_modes = {light.ColorMode.RGBWW} + + entity7 = entities[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1350,12 +1535,15 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: ] state = hass.states.get(entity4.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW] + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] state = hass.states.get(entity5.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW] state = hass.states.get(entity6.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + + state = hass.states.get(entity7.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] await hass.services.async_call( @@ -1370,6 +1558,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1385,10 +1574,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} + assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( @@ -1403,6 +1594,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1418,11 +1610,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} + assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint of the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1437,6 +1631,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1451,12 +1646,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: assert data == {"brightness": 128, "xy_color": (0.701, 0.299)} _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 0, 0)} - _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} + assert data == {"brightness": 128, "hs_color": (0.0, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( @@ -1471,6 +1667,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1486,11 +1683,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1505,6 +1704,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1520,10 +1720,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.1, 0.8)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} + assert data == {"brightness": 128, "hs_color": (125.176, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( @@ -1538,6 +1740,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1553,11 +1756,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.323, 0.329)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.392)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1572,6 +1777,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1587,11 +1793,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 43, 43)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)} + assert data == {"brightness": 128, "hs_color": (0.0, 66.406)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( @@ -1606,6 +1814,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1621,11 +1830,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1640,6 +1851,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1655,10 +1867,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 33, 26)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} + assert data == {"brightness": 128, "hs_color": (4.118, 79.688)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( @@ -1673,6 +1887,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1688,11 +1903,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 217, 185)} _, data = entity4.last_call("turn_on") + assert data == {"brightness": 128, "hs_color": (27.429, 27.451)} + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by decreasing green + blue assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} - _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} @@ -1705,6 +1922,7 @@ async def test_light_service_call_color_conversion_named_tuple( MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), + MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), ] @@ -1727,10 +1945,16 @@ async def test_light_service_call_color_conversion_named_tuple( } entity4 = entities[4] - entity4.supported_color_modes = {light.ColorMode.RGBW} + entity4.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBWW} + entity5.supported_color_modes = {light.ColorMode.RGBW} + + entity6 = entities[6] + entity6.supported_color_modes = {light.ColorMode.RGBWW} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1746,6 +1970,7 @@ async def test_light_service_call_color_conversion_named_tuple( entity3.entity_id, entity4.entity_id, entity5.entity_id, + entity6.entity_id, ], "brightness_pct": 25, "rgb_color": color_util.RGBColor(128, 0, 0), @@ -1761,8 +1986,10 @@ async def test_light_service_call_color_conversion_named_tuple( _, data = entity3.last_call("turn_on") assert data == {"brightness": 64, "rgb_color": (128, 0, 0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)} + assert data == {"brightness": 64, "hs_color": (0.0, 100.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)} + _, data = entity6.last_call("turn_on") assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)} @@ -2131,6 +2358,13 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: entity2.rgb_color = "Invalid" # Should be ignored entity2.xy_color = (0.1, 0.8) + entity3 = entities[3] + entity3.hs_color = (240, 100) + entity3.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -2152,6 +2386,12 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: assert state.attributes["rgb_color"] == (0, 255, 22) assert state.attributes["xy_color"] == (0.1, 0.8) + state = hass.states.get(entity3.entity_id) + assert state.attributes["color_mode"] == light.ColorMode.HS + assert state.attributes["hs_color"] == (240, 100) + assert state.attributes["rgb_color"] == (0, 0, 255) + assert state.attributes["xy_color"] == (0.136, 0.04) + async def test_services_filter_parameters( hass: HomeAssistant, @@ -2386,6 +2626,31 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test") + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + @pytest.mark.parametrize( ("color_mode", "supported_color_modes", "warning_expected"), [ @@ -2393,7 +2658,7 @@ def test_filter_supported_color_modes() -> None: (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_mode( +async def test_report_no_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2409,6 +2674,8 @@ class MockLightEntityEntity(light.LightEntity): _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not report a color mode" assert (expected_warning in caplog.text) is warning_expected @@ -2421,7 +2688,7 @@ class MockLightEntityEntity(light.LightEntity): (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_modes( +async def test_report_no_color_modes( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2437,6 +2704,8 @@ class MockLightEntityEntity(light.LightEntity): _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not set supported color modes" assert (expected_warning in caplog.text) is warning_expected @@ -2467,7 +2736,7 @@ class MockLightEntityEntity(light.LightEntity): (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), ], ) -def test_report_invalid_color_mode( +async def test_report_invalid_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2485,6 +2754,8 @@ class MockLightEntityEntity(light.LightEntity): _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = f"set to unsupported color mode {color_mode}" assert (expected_warning in caplog.text) is warning_expected diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 3fd1fbea95ebb6..adf6aa601ae27d 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -7,11 +7,11 @@ import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import HOST, HOST_REENTRY, NAME, UUID diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index 8849392b3dd0f7..b29fa75380151f 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -90,6 +90,14 @@ "isUSBPowerOn": True, "USBFaultStatus": "CLEAR", "isDFIPartialFull": True, + "isLaserDirty": False, + "surfaceType": "TILE", + "hopperStatus": None, + "scoopsSavedCount": 3769, + "isHopperRemoved": None, + "optimalLitterLevel": 450, + "litterLevelPercentage": 0.7, + "litterLevelState": "OPTIMAL", } FEEDER_ROBOT_DATA = { "id": 1, diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index ddfdf4249bd1cf..0eb48aa3060187 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -281,7 +281,7 @@ async def test_import_from_yaml_fails( assert not hass.states.get("camera.config_test") issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify("mock.file")}" + DOMAIN, f"no_access_path_{slugify('mock.file')}" ) assert issue assert issue.translation_key == "no_access_path" diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index cea0f96989320b..64dc1220683091 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -7,7 +7,7 @@ from aiolookin import Climate, Device, Remote -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo DEVICE_ID = "98F33163" MODULE = "homeassistant.components.lookin" diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index d59ed60796b594..6f7da09fa0da70 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,16 +8,16 @@ from loqedAPI import loqed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -zeroconf_data = zeroconf.ZeroconfServiceInfo( +zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.12.34"), ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index b2edaa071550fe..cc80bc08817c9b 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,7 +10,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.lutron_caseta import DOMAIN import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow from homeassistant.components.lutron_caseta.const import ( @@ -23,6 +22,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ENTRY_MOCK_DATA, MockBridge @@ -421,7 +421,7 @@ async def test_zeroconf_host_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -449,7 +449,7 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -472,7 +472,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", @@ -500,7 +500,7 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index dabee74fdc3265..ea0805b920ac9a 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -42,9 +42,8 @@ def expected_event_data(self) -> dict[str, Any] | None: Commands that are named with 'Subset' are expected not to be read from Room A. """ - if ( - self.expected_event_data_extra is None - or "Subset" in self.expected_event_data_extra["command"] + if self.expected_event_data_extra is None or ( + "Subset" in self.expected_event_data_extra["command"] and self.room_id not in SUBSET_ROOMS ): return None diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index eed776c132e864..24243fa2038404 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -14,10 +14,10 @@ from homeassistant import config_entries from homeassistant.components.matter.const import ADDON_SLUG, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 7c64f846df1651..9db2621f84faa9 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,7 +21,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import help_test_all, import_and_test_deprecated_constant_enum +from tests.common import ( + MockEntityPlatform, + help_test_all, + import_and_test_deprecated_constant_enum, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -116,20 +120,28 @@ def test_deprecated_constants_const( "grouping", ], ) -def test_support_properties(property_suffix: str) -> None: +def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: """Test support_*** properties explicitly.""" all_features = media_player.MediaPlayerEntityFeature(653887) feature = media_player.MediaPlayerEntityFeature[property_suffix.upper()] entity1 = MediaPlayerEntity() + entity1.hass = hass + entity1.platform = MockEntityPlatform(hass) entity1._attr_supported_features = media_player.MediaPlayerEntityFeature(0) entity2 = MediaPlayerEntity() + entity2.hass = hass + entity2.platform = MockEntityPlatform(hass) entity2._attr_supported_features = all_features entity3 = MediaPlayerEntity() + entity3.hass = hass + entity3.platform = MockEntityPlatform(hass) entity3._attr_supported_features = feature entity4 = MediaPlayerEntity() - entity4._attr_supported_features = all_features & ~feature + entity4.hass = hass + entity4.platform = MockEntityPlatform(hass) + entity4._attr_supported_features = all_features - feature assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -152,8 +164,7 @@ async def test_get_image_http( client = await hass_client_no_auth() with patch( - "homeassistant.components.media_player.MediaPlayerEntity." - "async_get_media_image", + "homeassistant.components.media_player.MediaPlayerEntity.async_get_media_image", return_value=(b"image", "image/jpeg"), ): resp = await client.get(state.attributes["entity_picture"]) @@ -447,3 +458,27 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/mochad/test_switch.py b/tests/components/mochad/test_switch.py index 9fea3b5c14c986..d7875246fac744 100644 --- a/tests/components/mochad/test_switch.py +++ b/tests/components/mochad/test_switch.py @@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockEntityPlatform + @pytest.fixture(autouse=True) def pymochad_mock(): @@ -25,7 +27,9 @@ def switch_mock(hass: HomeAssistant) -> mochad.MochadSwitch: """Mock switch.""" controller_mock = mock.MagicMock() dev_dict = {"address": "a1", "name": "fake_switch"} - return mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity = mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity.platform = MockEntityPlatform(hass) + return entity async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None: diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 24293377174c54..e1c0e08a113219 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -422,9 +422,9 @@ async def test_virtual_binary_sensor( assert hass.states.get(ENTITY_ID).state == expected for i, slave in enumerate(slaves): - entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i + 1}".replace(" ", "_") assert hass.states.get(entity_id).state == slave - unique_id = f"{SLAVE_UNIQUE_ID}_{i+1}" + unique_id = f"{SLAVE_UNIQUE_ID}_{i + 1}" entry = entity_registry.async_get(entity_id) assert entry.unique_id == unique_id diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 2ae4d6659e78f8..280deadc733899 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -10,10 +10,11 @@ from homeassistant.const import CONF_DEVICE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import com_port, patch_config_flow_modem -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=phone_modem.DEFAULT_PORT, pid="1340", vid="0572", diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 5b10d4d729e63a..4ec5e92cd727a5 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -6,12 +6,12 @@ import aiohttp from aiomodernforms import ModernFormsConnectionError -from homeassistant.components import zeroconf from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -66,7 +66,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -138,7 +138,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -170,7 +170,7 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", @@ -220,7 +220,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 77171b06ad66de..821e4fa0278c7c 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -6,12 +6,12 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.motion_blinds import const from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_API_KEY, CONF_HOST 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 @@ -346,7 +346,7 @@ async def test_config_flow_invalid_interface(hass: HomeAssistant) -> None: async def test_dhcp_flow(hass: HomeAssistant) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -380,7 +380,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if not Motionblinds.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -400,7 +400,7 @@ async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if device responded with invalid data.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index 842d862a222f5a..c403f9f072fac3 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry TEST_CONFIG_ENTRY_ID = "74565ad414754616000674c87bdc876c" -TEST_URL = f"http://test:{DEFAULT_PORT+1}" +TEST_URL = f"http://test:{DEFAULT_PORT + 1}" TEST_CAMERA_ID = 100 TEST_CAMERA_NAME = "Test Camera" TEST_CAMERA_ENTITY_ID = "camera.test_camera" diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index da6fbae32a3c3b..ed7dae266639b5 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 @@ -21,7 +21,7 @@ CONF_PORT: PORT, } -MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -31,7 +31,7 @@ properties={"txtvers": "1", "model": "TVM 7675"}, ) -MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 22f0416a2c61a0..2a1e4012f51f0d 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -18,7 +18,6 @@ from tests.typing import MqttMockPahoClient ENTRY_DEFAULT_BIRTH_MESSAGE = { - mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "homeassistant/status", mqtt.ATTR_PAYLOAD: "online", @@ -77,6 +76,7 @@ async def _async_job(self) -> None: async def setup_with_birth_msg_client_mock( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_client_mock: MqttMockPahoClient, ) -> AsyncGenerator[MqttMockPahoClient]: """Test sending birth message.""" @@ -89,6 +89,9 @@ async def setup_with_birth_msg_client_mock( entry = MockConfigEntry( domain=mqtt.DOMAIN, data=mqtt_config_entry_data or {mqtt.CONF_BROKER: "test-broker"}, + options=mqtt_config_entry_options or {}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 1daad0e39148ca..ad64b39a48069f 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -105,6 +105,8 @@ class FakeInfo: mqtt.CONF_BROKER: "test-broker", mqtt.CONF_DISCOVERY: False, }, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -132,7 +134,7 @@ class FakeInfo: await hass.async_block_till_done(wait_background_tasks=True) -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_publish( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient ) -> None: @@ -1022,8 +1024,8 @@ def _callback_b(msg: ReceiveMessage) -> None: @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1059,8 +1061,8 @@ async def test_restore_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1100,8 +1102,8 @@ async def test_restore_all_active_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribed_at_highest_qos( hass: HomeAssistant, @@ -1136,7 +1138,12 @@ async def test_initial_setup_logs_error( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for setup failure if initial client connection fails.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.side_effect = MagicMock(return_value=1) try: @@ -1239,7 +1246,12 @@ async def test_publish_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test publish error.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) # simulate an Out of memory error @@ -1381,7 +1393,10 @@ def _mock_ack(topic: str, qos: int = 0) -> tuple[int, int]: ) entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -1414,7 +1429,12 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, exception: Exception ) -> None: """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with patch( @@ -1495,17 +1515,19 @@ async def test_tls_version( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "birth", - mqtt.ATTR_PAYLOAD: "birth", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "birth", + mqtt.ATTR_PAYLOAD: "birth", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + } }, - } + ) ], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -1515,11 +1537,18 @@ async def test_custom_birth_message( hass: HomeAssistant, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1533,7 +1562,7 @@ async def test_custom_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_default_birth_message( @@ -1548,8 +1577,8 @@ async def test_default_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_BIRTH_MESSAGE: {}})], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @@ -1559,10 +1588,17 @@ async def test_no_birth_message( record_calls: MessageCallbackType, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test disabling birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) mock_debouncer.clear() @@ -1582,20 +1618,27 @@ async def test_no_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [ENTRY_DEFAULT_BIRTH_MESSAGE], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, ENTRY_DEFAULT_BIRTH_MESSAGE)], ) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.2) async def test_delayed_birth_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message does not happen until Home Assistant starts.""" hass.set_state(CoreState.starting) await hass.async_block_till_done() birth = asyncio.Event() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1619,7 +1662,7 @@ def wait_birth(msg: ReceiveMessage) -> None: @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_subscription_done_when_birth_message_is_sent( @@ -1637,26 +1680,37 @@ async def test_subscription_done_when_birth_message_is_sent( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "death", - mqtt.ATTR_PAYLOAD: "death", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + { + mqtt.CONF_BROKER: "mock-broker", }, - } + { + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "death", + mqtt.ATTR_PAYLOAD: "death", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + }, + ) ], ) async def test_custom_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1678,16 +1732,23 @@ async def test_default_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_WILL_MESSAGE: {}})], ) async def test_no_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1697,7 +1758,7 @@ async def test_no_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_subscribes_topics_on_connect( @@ -1730,7 +1791,7 @@ async def test_mqtt_subscribes_topics_on_connect( assert ("still/pending", 1) in subscribe_calls -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_mqtt_subscribes_wildcard_topics_in_correct_order( hass: HomeAssistant, mock_debouncer: asyncio.Event, @@ -1789,7 +1850,7 @@ def _assert_subscription_order(): @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_discovery_not_subscribes_when_disabled( @@ -1822,7 +1883,7 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_mqtt_subscribes_in_single_call( @@ -1848,7 +1909,7 @@ async def test_mqtt_subscribes_in_single_call( ] -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) @patch("homeassistant.components.mqtt.client.MAX_SUBSCRIBES_PER_CALL", 2) @patch("homeassistant.components.mqtt.client.MAX_UNSUBSCRIBES_PER_CALL", 2) async def test_mqtt_subscribes_and_unsubscribes_in_chunks( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index fbf393dc10514f..a34907adbaf7ee 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1887,7 +1887,12 @@ async def help_test_reloadable( mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } # Start the MQTT entry with the old config - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 38dbda50cdd5f8..072998f9b8d055 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -43,6 +43,28 @@ MOCK_CLIENT_CERT = b"## mock client certificate file ##" MOCK_CLIENT_KEY = b"## mock key file ##" +MOCK_ENTRY_DATA = { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} +MOCK_ENTRY_OPTIONS = { + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, +} + @pytest.fixture(autouse=True) def mock_finish_setup() -> Generator[MagicMock]: @@ -243,8 +265,10 @@ async def test_user_connection_works( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } + # Check we have the latest Config Entry version + assert result["result"].version == 1 + assert result["result"].minor_version == 2 # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 # Check config entry got setup @@ -283,7 +307,6 @@ async def test_user_connection_works_with_supervisor( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -324,7 +347,6 @@ async def test_user_v5_connection_works( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "another-broker", - "discovery": True, "port": 2345, "protocol": "5", } @@ -383,14 +405,12 @@ async def test_manual_config_set( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { "broker": "127.0.0.1", "port": 1883, - "discovery": True, }, ) # Check config entry got setup @@ -401,7 +421,11 @@ async def test_manual_config_set( async def test_user_single_instance(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} @@ -412,7 +436,11 @@ async def test_user_single_instance(hass: HomeAssistant) -> None: async def test_hassio_already_configured(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_HASSIO} @@ -424,7 +452,10 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: async def test_hassio_ignored(hass: HomeAssistant) -> None: """Test we supervisor discovered instance can be ignored.""" MockConfigEntry( - domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + domain=mqtt.DOMAIN, + source=config_entries.SOURCE_IGNORE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -934,43 +965,19 @@ async def test_addon_not_installed_failures( async def test_option_flow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - mock_try_connection: MagicMock, ) -> None: """Test config flow options.""" with patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}) ) as yaml_mock: - mqtt_mock = await mqtt_mock_entry() - mock_try_connection.return_value = True + await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - - mqtt_mock.async_connect.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 yaml_mock.reset_mock() @@ -992,12 +999,10 @@ async def test_option_flow( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.data == {mqtt.CONF_BROKER: "mock-broker"} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { @@ -1015,8 +1020,7 @@ async def test_option_flow( } await hass.async_block_till_done() - assert config_entry.title == "another-broker" - # assert that the entry was reloaded with the new config + # assert that the entry was reloaded with the new config assert yaml_mock.await_count @@ -1071,7 +1075,7 @@ async def test_bad_certificate( test_input.pop(mqtt.CONF_CLIENT_KEY) mqtt_mock = await mqtt_mock_entry() - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1088,11 +1092,11 @@ async def test_bad_certificate( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1109,14 +1113,14 @@ async def test_bad_certificate( test_input["set_ca_cert"] = set_ca_cert test_input["tls_insecure"] = tls_insecure - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) if test_error is not None: assert result["errors"]["base"] == test_error return - assert result["errors"] == {} + assert "errors" not in result @pytest.mark.parametrize( @@ -1148,7 +1152,7 @@ async def test_keepalive_validation( mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1161,22 +1165,23 @@ async def test_keepalive_validation( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" if error: with pytest.raises(vol.Invalid): - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) return - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert not result["errors"] + assert "errors" not in result + assert result["reason"] == "reconfigure_successful" async def test_disable_birth_will( @@ -1186,7 +1191,7 @@ async def test_disable_birth_will( mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" - mqtt_mock = await mqtt_mock_entry() + await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] hass.config_entries.async_update_entry( @@ -1199,26 +1204,10 @@ async def test_disable_birth_will( await hass.async_block_till_done() mock_reload_after_entry_update.reset_mock() - mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" - await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1238,12 +1227,14 @@ async def test_disable_birth_will( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + assert result["data"] == { + "birth_message": {}, + "discovery": True, + "discovery_prefix": "homeassistant", + "will_message": {}, + } + assert config_entry.data == {mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, @@ -1270,6 +1261,8 @@ async def test_invalid_discovery_prefix( data={ mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", }, @@ -1280,16 +1273,6 @@ async def test_invalid_discovery_prefix( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() @@ -1308,6 +1291,8 @@ async def test_invalid_discovery_prefix( assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + } + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", } @@ -1356,6 +1341,8 @@ async def test_option_flow_default_suggested_values( CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", @@ -1371,37 +1358,13 @@ async def test_option_flow_default_suggested_values( }, }, ) + await hass.async_block_till_done() # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - } - suggested = { - CONF_USERNAME: "user", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "us3r", - CONF_PASSWORD: "p4ss", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: True, "birth_qos": 1, "birth_retain": True, "will_qos": 2, @@ -1421,7 +1384,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: False, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1437,28 +1399,8 @@ async def test_option_flow_default_suggested_values( # Test updated default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - } - suggested = { - CONF_USERNAME: "us3r", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: False, "birth_qos": 2, "birth_retain": False, "will_qos": 1, @@ -1478,7 +1420,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: True, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1496,7 +1437,8 @@ async def test_option_flow_default_suggested_values( @pytest.mark.parametrize( - ("advanced_options", "step_id"), [(False, "options"), (True, "broker")] + ("advanced_options", "flow_result"), + [(False, FlowResultType.ABORT), (True, FlowResultType.FORM)], ) @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_skipping_advanced_options( @@ -1504,41 +1446,35 @@ async def test_skipping_advanced_options( mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, advanced_options: bool, - step_id: str, + flow_result: FlowResultType, ) -> None: """Test advanced options option.""" test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, - "advanced_options": advanced_options, } + if advanced_options: + test_input["advanced_options"] = True mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - # Initiate with a basic setup - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} + result = await config_entry.start_reconfigure_flow( + hass, show_advanced_options=advanced_options ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + assert ("advanced_options" in result["data_schema"].schema) == advanced_options + + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert result["step_id"] == step_id + assert result["type"] is flow_result @pytest.mark.parametrize( @@ -1582,7 +1518,12 @@ async def test_step_reauth( """Test that the reauth step works.""" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=test_input) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=test_input, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1658,7 +1599,12 @@ async def test_step_hassio_reauth( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1740,7 +1686,12 @@ async def test_step_hassio_reauth_no_discovery_info( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1762,11 +1713,15 @@ async def test_step_hassio_reauth_no_discovery_info( mock_try_connection.assert_not_called() -async def test_options_user_connection_fails( +async def test_reconfigure_user_connection_fails( hass: HomeAssistant, mock_try_connection_time_out: MagicMock ) -> None: """Test if connection cannot be made.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1775,11 +1730,11 @@ async def test_options_user_connection_fails( CONF_PORT: 1234, }, ) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM mock_try_connection_time_out.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345}, ) @@ -1800,7 +1755,11 @@ async def test_options_bad_birth_message_fails( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient ) -> None: """Test bad birth message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1813,13 +1772,6 @@ async def test_options_bad_birth_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1841,7 +1793,11 @@ async def test_options_bad_will_message_fails( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test bad will message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1854,13 +1810,6 @@ async def test_options_bad_will_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1878,15 +1827,16 @@ async def test_options_bad_will_message_fails( } -@pytest.mark.parametrize( - "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] -) @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient ) -> None: """Test config flow with advanced parameters from config.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1920,7 +1870,7 @@ async def test_try_connection_with_advanced_parameters( ) # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" defaults = { @@ -1944,9 +1894,8 @@ async def test_try_connection_with_advanced_parameters( assert get_suggested(result["data_schema"].schema, k) == v # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1961,9 +1910,8 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml @@ -1987,12 +1935,6 @@ async def test_try_connection_with_advanced_parameters( "/new/path", {"h3": "v3"}, ) - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() @@ -2005,7 +1947,11 @@ async def test_setup_with_advanced_settings( """Test config flow setup with advanced parameters.""" file_id = mock_process_uploaded_file.file_id - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2017,15 +1963,13 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} - ) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] # first iteration, basic settings - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2049,7 +1993,7 @@ async def test_setup_with_advanced_settings( assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema # second iteration, advanced settings with request for client cert - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2080,7 +2024,7 @@ async def test_setup_with_advanced_settings( assert result["data_schema"].schema[mqtt.CONF_WS_HEADERS] # third iteration, advanced settings with client cert and key set and bad json payload - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2105,7 +2049,7 @@ async def test_setup_with_advanced_settings( # fourth iteration, advanced settings with client cert and key set # and correct json payload for ws_headers - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2124,17 +2068,8 @@ async def test_setup_with_advanced_settings( }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { @@ -2153,8 +2088,6 @@ async def test_setup_with_advanced_settings( "header_2": "content_header_2", }, mqtt.CONF_CERTIFICATE: "auto", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", } @@ -2163,7 +2096,11 @@ async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test reconfiguration flow changing websockets transport settings.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2254,3 +2191,95 @@ async def test_reconfigure_flow_form( mqtt.CONF_WS_PATH: "/some_new_path", } await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 1, 2), + (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 2), + (1, 3, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 3), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock to a migratable or compatbible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + # Start MQTT + await mqtt_mock_entry() + await hass.async_block_till_done() + assert ( + config_entry.data | config_entry.options == MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS + ) + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_of_incompatible_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock an incompatible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + # Try to start MQTT with incompatible config entry + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b8499ba581293f..bbd60329d0a38f 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -17,10 +17,12 @@ ) from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator -default_config = { - "birth_message": {}, +default_entry_data = { "broker": "mock-broker", } +default_entry_options = { + "birth_message": {}, +} async def test_entry_diagnostics( @@ -38,7 +40,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": {"entities": [], "triggers": []}, } @@ -123,7 +125,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [expected_device], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @@ -132,20 +134,24 @@ async def test_entry_diagnostics( ) == { "connected": True, "device": expected_device, - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - CONF_PASSWORD: "hunter2", - CONF_USERNAME: "my_user", - } + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PASSWORD: "hunter2", + CONF_USERNAME: "my_user", + }, + { + mqtt.CONF_BIRTH_MESSAGE: {}, + }, + ) ], ) async def test_redact_diagnostics( @@ -157,9 +163,12 @@ async def test_redact_diagnostics( ) -> None: """Test redacting diagnostics.""" mqtt_mock = await mqtt_mock_entry() - expected_config = dict(default_config) - expected_config["password"] = "**REDACTED**" - expected_config["username"] = "**REDACTED**" + expected_config = { + "data": dict(default_entry_data), + "options": dict(default_entry_options), + } + expected_config["data"]["password"] = "**REDACTED**" + expected_config["data"]["username"] = "**REDACTED**" config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 8a674a4e1cdbfd..982167feee1496 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -195,8 +195,8 @@ async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -356,7 +356,7 @@ async def test_invalid_device_discovery_config( async_fire_mqtt_message( hass, "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, ' '"cmps": ""}', + '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, "cmps": ""}', ) await hass.async_block_till_done() assert ( @@ -1946,7 +1946,12 @@ async def test_cleanup_device_multiple_config_entries( mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2042,7 +2047,12 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) -> None: """Test discovered device is cleaned up when removed through MQTT.""" mqtt_mock = await mqtt_mock_entry() - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2437,12 +2447,14 @@ async def test_no_implicit_state_topic_switch( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", - } + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", + }, + ) ], ) async def test_complex_discovery_topic_prefix( @@ -2497,7 +2509,13 @@ def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( patch( @@ -2562,7 +2580,13 @@ def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ad9d65894d0e64..4e0873c6e1b516 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -695,7 +695,12 @@ async def test_reload_entry_with_restored_subscriptions( ) -> None: """Test reloading the config entry with with subscriptions restored.""" # Setup the MQTT entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -800,7 +805,10 @@ async def test_default_entry_setting_are_applied( # Config entry data is incomplete but valid according the schema entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={"broker": "test-broker", "port": 1234} + domain=mqtt.DOMAIN, + data={"broker": "test-broker", "port": 1234}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) @@ -1614,6 +1622,8 @@ async def test_unload_config_entry( entry = MockConfigEntry( domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index dbca09e803c4cb..f8c66a3de1d500 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -72,7 +72,7 @@ payload_on: "on" payload_off: "off" -config with brightness and color temp +config with brightness and color temp (mired) mqtt: light: @@ -88,6 +88,23 @@ payload_on: "on" payload_off: "off" +config with brightness and color temp (Kelvin) + +mqtt: + light: + - name: "Office Light Color Temp" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + brightness_state_topic: "office/rgb1/brightness/status" + brightness_command_topic: "office/rgb1/brightness/set" + brightness_scale: 99 + color_temp_kelvin: true + color_temp_state_topic: "office/rgb1/color_temp/status" + color_temp_command_topic: "office/rgb1/color_temp/set" + qos: 0 + payload_on: "on" + payload_off: "off" + config with brightness and effect mqtt: @@ -305,6 +322,101 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "min_kelvin", "max_kelvin"), + [ + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_mireds": 180, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_mireds": 400, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_kelvin": 5555, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_kelvin": 2500, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ], +) +async def test_no_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "ON") + state = hass.states.get("light.test") + assert state is not None and state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_MIN_COLOR_TEMP_KELVIN) == min_kelvin + assert state.attributes.get(light.ATTR_MAX_COLOR_TEMP_KELVIN) == max_kelvin + + @pytest.mark.parametrize( "hass_config", [ @@ -431,6 +543,76 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + ("hass_config", "payload", "kelvin"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": False, + } + } + }, + "300", + 3333, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": True, + } + } + }, + "3333", + 3333, + ), + ], + ids=["mireds", "kelvin"], +) +async def test_controlling_color_mode_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload: str, + kelvin: int, +) -> None: + """Test the controlling of the color mode state via topic.""" + color_modes = ["color_temp"] + + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "test_light_color_temp/status", "ON") + async_fire_mqtt_message(hass, "test_light_color_temp/brightness/status", "70") + async_fire_mqtt_message(hass, "test_light_color_temp/color_temp/status", payload) + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 70 + assert light_state.attributes["color_temp_kelvin"] == kelvin + assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" + assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + @pytest.mark.parametrize( "hass_config", [ @@ -1295,25 +1477,47 @@ async def test_sending_mqtt_rgbww_command_with_template( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "color_temp_kelvin": False, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } } - } - } + }, + "10", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (0.5 * value) | round(0) }}", + "color_temp_kelvin": True, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + }, + "5000", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, payload: str ) -> None: """Test the sending of Color Temp command with template.""" mqtt_mock = await mqtt_mock_entry() @@ -1326,14 +1530,14 @@ async def test_sending_mqtt_color_temp_command_with_template( mqtt_mock.async_publish.assert_has_calls( [ call("test_light_color_temp/set", "on", 0, False), - call("test_light_color_temp/color_temp/set", "10", 0, False), + call("test_light_color_temp/color_temp/set", payload, 0, False), ], any_order=True, ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes["color_temp"] == 100 + assert state.attributes["color_temp_kelvin"] == 10000 @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c127c86de39b9d..512e409143834e 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -14,7 +14,7 @@ rgb: true xy: true -Configuration with RGB, brightness, color temp and effect: +Configuration with RGB, brightness, color temp (mireds) and effect: mqtt: light: @@ -24,10 +24,11 @@ command_topic: "home/rgb1/set" brightness: true color_temp: true + color_temp_kelvin: false effect: true rgb: true -Configuration with RGB, brightness and color temp: +Configuration with RGB, brightness and color temp (Kelvin): mqtt: light: @@ -38,6 +39,7 @@ brightness: true rgb: true color_temp: true + color_temp_kelvin: true Configuration with RGB, brightness: @@ -399,24 +401,50 @@ async def test_fail_setup_if_color_modes_invalid( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "color_temp_payload_value"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "supported_color_modes": "color_temp", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": False, + "supported_color_modes": "color_temp", + } } - } - } + }, + 5208, + 192, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": True, + "supported_color_modes": "color_temp", + } + } + }, + 5208, + 5208, + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + color_temp_payload_value: int, ) -> None: """Test setup with single color_mode.""" await mqtt_mock_entry() @@ -424,13 +452,19 @@ async def test_single_color_mode( assert state.state == STATE_UNKNOWN await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 + hass, "light.test", brightness=50, color_temp_kelvin=kelvin ) + payload = { + "state": "ON", + "brightness": 50, + "color_mode": "color_temp", + "color_temp": color_temp_payload_value, + } async_fire_mqtt_message( hass, "test_light", - '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + json_dumps(payload), ) color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") @@ -788,6 +822,96 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get("brightness") == 128 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + "color_temp": True, + "color_temp_kelvin": True, + "effect": True, + "rgb": True, + "xy": True, + "hs": True, + "qos": "0", + } + } + } + ], +) +async def test_controlling_state_color_temp_kelvin( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic in Kelvin mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + expected_features = ( + light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness":255,' + '"color_temp":155,' + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.323, 0.329) + assert state.attributes.get("hs_color") == (0.0, 0.0) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"brightness":255,' + '"color":null,' + '"color_temp":6451,' # Kelvin + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 255, + 253, + 249, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6451 + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color + assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color + + @pytest.mark.parametrize( "hass_config", [ @@ -2591,30 +2715,82 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_kelvin", "max_kelvin"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_mireds": 370, # 2702 Kelvin + } } - } - } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_mireds": 150, # 6666 Kelvin + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_kelvin": 2702, + } + } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_kelvin": 6666, + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), ], ) -async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +async def test_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, ) -> None: - """Test setting min_mireds and max_mireds.""" + """Test setting min_color_temp_kelvin and max_color_temp_kelvin.""" await mqtt_mock_entry() state = hass.states.get("light.test") - assert state.attributes.get("min_mireds") == 153 - assert state.attributes.get("max_mireds") == 370 + assert state.attributes.get("min_color_temp_kelvin") == min_kelvin + assert state.attributes.get("max_color_temp_kelvin") == max_kelvin @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 4d2b93ff159529..568d86f8bd984f 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -179,25 +179,50 @@ async def test_rgb_light( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light/set", - "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", - "command_off_template": "off", - "brightness_template": "{{ value.split(',')[1] }}", - "color_temp_template": "{{ value.split(',')[2] }}", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } } - } - } + }, + 5208, + "192", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } + } + }, + 5208, + "5208", + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the color mode when we only have one supported color_mode.""" await mqtt_mock_entry() @@ -206,15 +231,15 @@ async def test_single_color_mode( assert state.state == STATE_UNKNOWN await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 + hass, "light.test", brightness=50, color_temp_kelvin=kelvin ) - async_fire_mqtt_message(hass, "test_light", "on,50,192") + async_fire_mqtt_message(hass, "test_light", f"on,50,{payload}") color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == kelvin assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -392,39 +417,80 @@ async def test_state_brightness_color_effect_temp_change_via_topic( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light_rgb/set", - "command_on_template": "on," - "{{ brightness|d }}," - "{{ color_temp|d }}," - "{{ red|d }}-" - "{{ green|d }}-" - "{{ blue|d }}," - "{{ hue|d }}-" - "{{ sat|d }}", - "command_off_template": "off", - "effect_list": ["colorloop", "random"], - "optimistic": True, - "state_template": '{{ value.split(",")[0] }}', - "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].split("-")[0] }}', - "green_template": '{{ value.split(",")[3].split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].split("-")[2] }}', - "effect_template": '{{ value.split(",")[4] }}', - "qos": 2, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": False, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } } - } - } + }, + 14285, + "on,,70,--,-", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": True, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } + }, + }, + 14285, + "on,,14285,--,-", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -465,14 +531,15 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=kelvin) + # Assert mireds or Kelvin as payload mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,--,-", 2, False + "test_light_rgb/set", payload, 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp_kelvin") == 14285 + assert state.attributes.get("color_temp_kelvin") == kelvin # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 5b7984cad62325..d65f1a4d66176f 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -313,7 +313,12 @@ async def test_default_entity_and_device_name( hass.set_state(CoreState.starting) await hass.async_block_till_done() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672178..7bdd39e81a711f 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 37bf6982b7a091..dd72902056dac9 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -231,6 +231,8 @@ async def test_waiting_for_client_not_loaded( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -286,6 +288,8 @@ async def test_waiting_for_client_entry_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -314,6 +318,8 @@ async def test_waiting_for_client_setup_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -341,6 +347,8 @@ async def test_waiting_for_client_timeout( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -360,6 +368,8 @@ async def test_waiting_for_client_with_disabled_entry( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index c700060889c217..89cda62961b829 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,10 +15,10 @@ from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index 8ee62996f9249e..aee6657b270331 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -179,4 +179,4 @@ def uri(self) -> str | None: """Return the URI.""" if not self._requested_state: return None - return f"http://{self._state["ip"]}" + return f"http://{self._state['ip']}" diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 6c11399c888e39..80c6e86f420b18 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -6,16 +6,16 @@ from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest -from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("10.10.2.3"), ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 97a314b0bf4d90..ba89405bc97935 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -9,11 +9,15 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -248,13 +252,13 @@ async def test_discovery_link_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery_info, ), ) @@ -384,13 +388,13 @@ async def test_import_discovery_integration( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery, ), ) @@ -432,7 +436,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 3d28c1abf23923..f08eeb82a1d281 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -10,12 +10,12 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .common import ( CLIENT_ID, @@ -36,7 +36,7 @@ APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" RAND_SUBSCRIBER_SUFFIX = "ABCDEF" -FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( +FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" ) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 276dd45d0ab29e..051f7bb87e4bb7 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -1012,9 +1012,9 @@ async def test_media_permission_unauthorized( client = await hass_client() response = await client.get(media_url) - assert ( - response.status == HTTPStatus.UNAUTHORIZED - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.UNAUTHORIZED, ( + f"Response not matched: {response}" + ) async def test_multiple_devices( @@ -1306,9 +1306,9 @@ async def test_media_store_load_filesystem_error( response = await client.get( f"/api/nest/event_media/{device.id}/{event_identifier}" ) - assert ( - response.status == HTTPStatus.NOT_FOUND - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.NOT_FOUND, ( + f"Response not matched: {response}" + ) @pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr new file mode 100644 index 00000000000000..6ad1b9e78ba9ee --- /dev/null +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entity[button.bubendorff_blind_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.bubendorff_blind_preferred_position', + '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': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999993-DeviceType.NBO-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.bubendorff_blind_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bubendorff blind Preferred position', + }), + 'context': , + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.entrance_blinds_preferred_position', + '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': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999992-DeviceType.NBR-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Entrance Blinds Preferred position', + }), + 'context': , + 'entity_id': 'button.entrance_blinds_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py new file mode 100644 index 00000000000000..681e42af051d63 --- /dev/null +++ b/tests/components/netatmo/test_button.py @@ -0,0 +1,72 @@ +"""The tests for Netatmo button.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BUTTON, + entity_registry, + snapshot, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + button_entity = "button.entrance_blinds_preferred_position" + + assert hass.states.get(button_entity).state == STATE_UNKNOWN + + # Test button press + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -2, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + assert (state := hass.states.get(button_entity)) + assert state.state != STATE_UNKNOWN diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 436f75b12ecd25..f5714d69a988af 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, @@ -20,6 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .conftest import CLIENT_ID @@ -46,13 +49,13 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 724a0568580669..3c83bb57c43a93 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -5,7 +5,6 @@ from pynetgear import DEFAULT_USER import pytest -from homeassistant.components import ssdp from homeassistant.components.netgear.const import ( CONF_CONSIDER_HOME, DOMAIN, @@ -23,6 +22,12 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -208,14 +213,14 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -228,13 +233,13 @@ async def test_ssdp_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, }, ), ) @@ -253,14 +258,14 @@ async def test_ssdp_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URLipv6, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -273,14 +278,14 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -305,14 +310,14 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], - ssdp.ATTR_UPNP_PRESENTATION_URL: URL_SSL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], + ATTR_UPNP_PRESENTATION_URL: URL_SSL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py index f3ccacbd0649a7..bbac7ca2f7cd93 100644 --- a/tests/components/network/__init__.py +++ b/tests/components/network/__init__.py @@ -1 +1,4 @@ """Tests for the Network Configuration integration.""" + +NO_LOOPBACK_IPADDR = "192.168.1.5" +LOOPBACK_IPADDR = "127.0.0.1" diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py index d5fbb95a8147a9..db2f268e9689f7 100644 --- a/tests/components/network/conftest.py +++ b/tests/components/network/conftest.py @@ -1,14 +1,51 @@ """Tests for the Network Configuration integration.""" from collections.abc import Generator -from unittest.mock import _patch +from unittest.mock import MagicMock, Mock, _patch, patch +import ifaddr import pytest +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + + +def _generate_mock_adapters(): + mock_lo0 = Mock(spec=ifaddr.Adapter) + mock_lo0.nice_name = "lo0" + mock_lo0.ips = [ifaddr.IP(LOOPBACK_IPADDR, 8, "lo0")] + mock_lo0.index = 0 + mock_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.index = 1 + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP(NO_LOOPBACK_IPADDR, 23, "eth1")] + mock_eth1.index = 2 + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + mock_vtun0.index = 3 + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +def _mock_socket(sockname: list[str]) -> Generator[None]: + """Mock the network socket.""" + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=sockname)), + ): + yield + @pytest.fixture(autouse=True) -def mock_network(): +def mock_network() -> Generator[None]: """Override mock of network util's async_get_adapters.""" + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + yield @pytest.fixture(autouse=True) @@ -19,3 +56,21 @@ def override_mock_get_source_ip( mock_get_source_ip.stop() yield mock_get_source_ip.start() + + +@pytest.fixture +def mock_socket(request: pytest.FixtureRequest) -> Generator[None]: + """Mock the network socket.""" + yield from _mock_socket(request.param) + + +@pytest.fixture +def mock_socket_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([LOOPBACK_IPADDR]) + + +@pytest.fixture +def mock_socket_no_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([NO_LOOPBACK_IPADDR]) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index dca31106dba9eb..a2352e6af9e8d5 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -4,7 +4,6 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch -import ifaddr import pytest from homeassistant.components import network @@ -20,16 +19,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from tests.typing import WebSocketGenerator - -_NO_LOOPBACK_IPADDR = "192.168.1.5" -_LOOPBACK_IPADDR = "127.0.0.1" - +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR -def _mock_socket(sockname): - mock_socket = MagicMock() - mock_socket.getsockname = Mock(return_value=sockname) - return mock_socket +from tests.typing import WebSocketGenerator def _mock_cond_socket(sockname): @@ -54,42 +46,13 @@ def _mock_socket_exception(exc): return mock_socket -def _generate_mock_adapters(): - mock_lo0 = Mock(spec=ifaddr.Adapter) - mock_lo0.nice_name = "lo0" - mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] - mock_lo0.index = 0 - mock_eth0 = Mock(spec=ifaddr.Adapter) - mock_eth0.nice_name = "eth0" - mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] - mock_eth0.index = 1 - mock_eth1 = Mock(spec=ifaddr.Adapter) - mock_eth1.nice_name = "eth1" - mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] - mock_eth1.index = 2 - mock_vtun0 = Mock(spec=ifaddr.Adapter) - mock_vtun0.nice_name = "vtun0" - mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] - mock_vtun0.index = 3 - return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] - - +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_detect_interfaces_setting_non_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a non-loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -141,22 +104,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( ] +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_detect_interfaces_setting_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -207,22 +161,14 @@ async def test_async_detect_interfaces_setting_loopback_route( ] +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_detect_interfaces_setting_empty_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns nothing.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -277,15 +223,9 @@ async def test_async_detect_interfaces_setting_exception( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route throws an exception.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket_exception(AttributeError), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -339,6 +279,7 @@ async def test_async_detect_interfaces_setting_exception( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_interfaces_configured_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -348,18 +289,9 @@ async def test_interfaces_configured_from_storage( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] @@ -422,15 +354,9 @@ async def test_interfaces_configured_from_storage_websocket_update( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=[NO_LOOPBACK_IPADDR])), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -546,6 +472,7 @@ async def test_interfaces_configured_from_storage_websocket_update( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_matching_interface( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -556,22 +483,13 @@ async def test_async_get_source_ip_matching_interface( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_interface_not_match( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -582,22 +500,14 @@ async def test_async_get_source_ip_interface_not_match( "data": {ATTR_CONFIGURED_ADAPTERS: ["vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_determine_target( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -608,22 +518,13 @@ async def test_async_get_source_ip_cannot_determine_target( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_ipv4_broadcast_addresses_default( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -634,24 +535,15 @@ async def test_async_get_ipv4_broadcast_addresses_default( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255") } +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_get_ipv4_broadcast_addresses_multiple( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -662,18 +554,8 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255"), @@ -682,6 +564,7 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( } +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -692,24 +575,23 @@ async def test_async_get_source_ip_no_enabled_addresses( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert ( + await network.async_get_source_ip(hass, MDNS_TARGET_IP) + == NO_LOOPBACK_IPADDR + ) assert "source address detection may be inaccurate" in caplog.text +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -720,15 +602,9 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -753,7 +629,7 @@ async def test_async_get_source_ip_no_ip_loopback( ), patch( "homeassistant.components.network.util.socket.socket", - return_value=_mock_cond_socket(_LOOPBACK_IPADDR), + return_value=_mock_cond_socket(LOOPBACK_IPADDR), ), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index a61cc5204f6899..865e1303cb0b75 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100.0), + (0, {ATTR_ENTITY_ID: "light.light"}, 100), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 19.607843137254903, + 20, ), ], ) diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py new file mode 100644 index 00000000000000..91226950aba31c --- /dev/null +++ b/tests/components/nmbs/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the NMBS integration.""" + +import json +from typing import Any + +from tests.common import load_fixture + + +def mock_api_unavailable() -> dict[str, Any]: + """Mock for unavailable api.""" + return -1 + + +def mock_station_response() -> dict[str, Any]: + """Mock for valid station response.""" + dummy_stations_response: dict[str, Any] = json.loads( + load_fixture("stations.json", "nmbs") + ) + + return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py new file mode 100644 index 00000000000000..69200fc4c98b11 --- /dev/null +++ b/tests/components/nmbs/conftest.py @@ -0,0 +1,58 @@ +"""NMBS tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_TO, + DOMAIN, +) + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nmbs.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nmbs_client() -> Generator[AsyncMock]: + """Mock a NMBS client.""" + with ( + patch( + "homeassistant.components.nmbs.iRail", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nmbs.config_flow.iRail", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_stations.return_value = load_json_object_fixture( + "stations.json", DOMAIN + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi", + data={ + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_TO: "BE.NMBS.008814001", + }, + unique_id="BE.NMBS.008812005_BE.NMBS.008814001", + ) diff --git a/tests/components/nmbs/fixtures/stations.json b/tests/components/nmbs/fixtures/stations.json new file mode 100644 index 00000000000000..b774e064f7895b --- /dev/null +++ b/tests/components/nmbs/fixtures/stations.json @@ -0,0 +1,30 @@ +{ + "version": "1.3", + "timestamp": "1720252400", + "station": [ + { + "@id": "http://irail.be/stations/NMBS/008812005", + "id": "BE.NMBS.008812005", + "name": "Brussels-North", + "locationX": "4.360846", + "locationY": "50.859663", + "standardname": "Brussel-Noord/Bruxelles-Nord" + }, + { + "@id": "http://irail.be/stations/NMBS/008813003", + "id": "BE.NMBS.008813003", + "name": "Brussels-Central", + "locationX": "4.356801", + "locationY": "50.845658", + "standardname": "Brussel-Centraal/Bruxelles-Central" + }, + { + "@id": "http://irail.be/stations/NMBS/008814001", + "id": "BE.NMBS.008814001", + "name": "Brussels-South/Brussels-Midi", + "locationX": "4.336531", + "locationY": "50.835707", + "standardname": "Brussel-Zuid/Bruxelles-Midi" + } + ] +} diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py new file mode 100644 index 00000000000000..6e55f89e54afbf --- /dev/null +++ b/tests/components/nmbs/test_config_flow.py @@ -0,0 +1,320 @@ +"""Test the NMBS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +DUMMY_DATA_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussel-Noord/Bruxelles-Nord", + "STAT_BRUSSELS_CENTRAL": "Brussel-Centraal/Bruxelles-Central", + "STAT_BRUSSELS_SOUTH": "Brussel-Zuid/Bruxelles-Midi", +} + +DUMMY_DATA_ALTERNATIVE_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussels-North", + "STAT_BRUSSELS_CENTRAL": "Brussels-Central", + "STAT_BRUSSELS_SOUTH": "Brussels-South/Brussels-Midi", +} + +DUMMY_DATA: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "BE.NMBS.008812005", + "STAT_BRUSSELS_CENTRAL": "BE.NMBS.008813003", + "STAT_BRUSSELS_SOUTH": "BE.NMBS.008814001", +} + + +async def test_full_flow( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + } + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + + +async def test_same_station( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test selecting the same station.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "same_station"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_exists( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by user and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +async def test_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow by user which filled in data for connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_LIVE: "BE.NMBS.008813003", + CONF_STATION_TO: "BE.NMBS.008814001", + } + assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + + +async def test_step_import_abort_if_already_setup( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user which filled in data for connection for already existing connection.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by import and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +@pytest.mark.parametrize( + ("config", "reason"), + [ + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: "Utrecht Centraal", + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: "Utrecht Centraal", + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + }, + "same_station", + ), + ], +) +async def test_invalid_station_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + config: dict[str, Any], + reason: str, +) -> None: + """Test importing invalid YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_sensor_id_migration_standardname( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id + + +async def test_sensor_id_migration_localized_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index d4ddc261f1e7c9..efc6c6c5abc40f 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -6,11 +6,11 @@ from requests.exceptions import RequestException from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .mock import DHCP_FORMATTED_MAC, HOST, MOCK_INFO, NAME, setup_nuki_integration @@ -151,9 +151,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) @@ -196,9 +194,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: await setup_nuki_integration(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index ffebd62fcbfde0..6b24c15f18a541 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -243,18 +243,26 @@ async def test_action_legacy( async def test_capabilities( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test getting capabilities.""" - entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id="abcdefgh" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id ) capabilities = await device_action.async_get_action_capabilities( hass, { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.id, + "entity_id": entity_entry.id, "type": "set_value", }, ) @@ -267,18 +275,26 @@ async def test_capabilities( async def test_capabilities_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test getting capabilities.""" - entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id="abcdefgh" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id ) capabilities = await device_action.async_get_action_capabilities( hass, { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.entity_id, + "entity_id": entity_entry.entity_id, "type": "set_value", }, ) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 537b6aba5ac08a..ed9c87f2f90c01 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,7 +6,6 @@ from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -20,6 +19,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .util import _get_mock_nutclient @@ -38,7 +38,7 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py index b88f0a5c874f4b..7b483514dcfc0f 100644 --- a/tests/components/obihai/__init__.py +++ b/tests/components/obihai/__init__.py @@ -1,7 +1,7 @@ """Tests for the Obihai Integration.""" -from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo USER_INPUT = { CONF_HOST: "10.10.10.30", @@ -9,7 +9,7 @@ CONF_USERNAME: "admin", } -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="obi200", ip="192.168.1.100", macaddress="9cadef000000", diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e069648671889a..d7d7e43e99c30c 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -6,10 +6,11 @@ from pyoctoprintapi import ApiError, DiscoverySettings from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.octoprint.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -183,7 +184,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -252,7 +253,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -525,7 +526,7 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -551,7 +552,7 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 3202b42d9b397f..202f7385697612 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -55,9 +55,9 @@ async def test_chat( Message(role="user", content="test message"), ] - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert result.response.speech["plain"]["speech"] == "test response" # Test Conversation tracing @@ -106,9 +106,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] @@ -341,7 +341,7 @@ def response(*args, **kwargs) -> dict: for i in range(5): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id="1234", context=Context(), agent_id=mock_config_entry.entry_id, @@ -432,7 +432,7 @@ async def test_message_history_pruning( for i in range(3): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=None, context=Context(), agent_id=mock_config_entry.entry_id, @@ -463,9 +463,9 @@ async def test_message_history_pruning( context=Context(), agent_id=mock_config_entry.entry_id, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) # Only the most recent histories should remain assert len(agent._history) == 2 @@ -490,7 +490,7 @@ async def test_message_history_unlimited( for i in range(100): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=conversation_id, context=Context(), agent_id=mock_config_entry.entry_id, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index ed15cac94bea3d..9c025fe33af24c 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -7,57 +7,58 @@ from pyownet.protocol import ProtocolError -from homeassistant.const import Platform - from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: Platform, device_ids: list[str] -) -> None: +def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" - main_dir_return_value = [] - sub_dir_side_effect = [] - main_read_side_effect = [] - sub_read_side_effect = [] + dir_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = {} + + # Setup directory listing + dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] for device_id in device_ids: - _setup_owproxy_mock_device( - main_dir_return_value, - sub_dir_side_effect, - main_read_side_effect, - sub_read_side_effect, - device_id, - platform, - ) - - # Ensure enough read side effect - dir_side_effect = [main_dir_return_value, *sub_dir_side_effect] - read_side_effect = ( - main_read_side_effect - + sub_read_side_effect - + [ProtocolError("Missing injected value")] * 20 - ) - owproxy.return_value.dir.side_effect = dir_side_effect - owproxy.return_value.read.side_effect = read_side_effect + _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) + + def _dir(path: str) -> Any: + if (side_effect := dir_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _dir call: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + def _read(path: str) -> Any: + if (side_effect := read_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _read call: {path}") + if len(side_effect) == 0: + raise ProtocolError(f"Missing injected value for: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + owproxy.return_value.dir.side_effect = _dir + owproxy.return_value.read.side_effect = _read def _setup_owproxy_mock_device( - main_dir_return_value: list, - sub_dir_side_effect: list, - main_read_side_effect: list, - sub_read_side_effect: list, - device_id: str, - platform: Platform, + dir_side_effect: dict[str, list], read_side_effect: dict[str, list], device_id: str ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] - # Setup directory listing - main_dir_return_value += [f"/{device_id}/"] if "branches" in mock_device: # Setup branch directory listing for branch, branch_details in mock_device["branches"].items(): + sub_dir_side_effect = dir_side_effect.setdefault( + f"/{device_id}/{branch}", [] + ) sub_dir_side_effect.append( [ # dir on branch f"/{device_id}/{branch}/{sub_device_id}/" @@ -65,46 +66,31 @@ def _setup_owproxy_mock_device( ] ) - _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, - mock_device, - device_id, - platform, - ) + _setup_owproxy_mock_device_reads(read_side_effect, mock_device, "/", device_id) if "branches" in mock_device: - for branch_details in mock_device["branches"].values(): + for branch, branch_details in mock_device["branches"].items(): for sub_device_id, sub_device in branch_details.items(): _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, + read_side_effect, sub_device, + f"/{device_id}/{branch}/", sub_device_id, - platform, ) def _setup_owproxy_mock_device_reads( - main_read_side_effect: list, - sub_read_side_effect: list, - mock_device: Any, - device_id: str, - platform: Platform, + read_side_effect: dict[str, list], mock_device: Any, root_path: str, device_id: str ) -> None: """Set up mock for owproxy.""" # Setup device reads - main_read_side_effect += [device_id[0:2].encode()] - if ATTR_INJECT_READS in mock_device: - main_read_side_effect += mock_device[ATTR_INJECT_READS] - - # Setup sub-device reads - device_sensors = mock_device.get(platform, []) - if platform is Platform.SENSOR and device_id.startswith("12"): - # We need to check if there is TAI8570 plugged in - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors - ) - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors + family_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}/family", [] ) + family_read_side_effect += [device_id[0:2].encode()] + if ATTR_INJECT_READS in mock_device: + for k, v in mock_device[ATTR_INJECT_READS].items(): + device_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}{k}", [] + ) + device_read_side_effect += v diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 65a86b58f2f037..9d4303eaa1c664 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -32,7 +32,7 @@ def get_device_id(request: pytest.FixtureRequest) -> str: @pytest.fixture(name="config_entry") -def get_config_entry(hass: HomeAssistant) -> ConfigEntry: +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -54,14 +54,14 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="owproxy") -def get_owproxy() -> MagicMock: +def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> MagicMock: +def get_owproxy_with_connerror() -> Generator[MagicMock]: """Mock owproxy.""" with patch( "homeassistant.components.onewire.onewirehub.protocol.proxy", diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index a1bab9807d5af2..4c05442eadc8d2 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,333 +2,243 @@ from pyownet.protocol import ProtocolError -from homeassistant.components.onewire.const import Platform - -ATTR_DEVICE_FILE = "device_file" ATTR_INJECT_READS = "inject_reads" MOCK_OWPROXY_DEVICES = { "00.111111111111": { - ATTR_INJECT_READS: [ - b"", # read device type - ], + ATTR_INJECT_READS: { + "/type": [b""], + }, }, "05.111111111111": { - ATTR_INJECT_READS: [ - b"DS2405", # read device type - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2405"], + "/PIO": [b" 1"], + }, }, "10.111111111111": { - ATTR_INJECT_READS: [ - b"DS18S20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18S20"], + "/temperature": [b" 25.123"], + }, }, "12.111111111111": { - ATTR_INJECT_READS: [ - b"DS2406", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 1025.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2406"], + # TAI8570 values are read twice: + # - once during init to make sure TAI8570 is accessible + # - once during first update to get the actual values + "/TAI8570/temperature": [b" 25.123", b" 25.123"], + "/TAI8570/pressure": [b" 1025.123", b" 1025.123"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/latch.A": [b" 1"], + "/latch.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 251123"}, - {ATTR_INJECT_READS: b" 248125"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + } }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: [ - ProtocolError(), # read device type - ], + ATTR_INJECT_READS: {"/type": [ProtocolError()]}, }, "1F.111111111111": { - ATTR_INJECT_READS: [ - b"DS2409", # read device type - ], + ATTR_INJECT_READS: {"/type": [b"DS2409"]}, "branches": { "aux": {}, "main": { "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_INJECT_READS: b" 251123", - }, - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_INJECT_READS: b" 248125", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + }, }, }, }, }, "22.111111111111": { - ATTR_INJECT_READS: [ - b"DS1822", # read device type - ], - Platform.SENSOR: [ - { - ATTR_INJECT_READS: ProtocolError, - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1822"], + "/temperature": [ProtocolError], + }, }, "26.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "28.111111111111": { - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + "/tempres": [b" 12"], + }, }, "28.222222222222": { # This device has precision options in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature9": [b" 26.984"], + }, }, "28.222222222223": { # This device has an illegal precision option in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + }, }, "29.111111111111": { - ATTR_INJECT_READS: [ - b"DS2408", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2408"], + "/PIO.0": [b" 1"], + "/PIO.1": [b" 0"], + "/PIO.2": [b" 1"], + "/PIO.3": [ProtocolError], + "/PIO.4": [b" 1"], + "/PIO.5": [b" 0"], + "/PIO.6": [b" 1"], + "/PIO.7": [b" 0"], + "/latch.0": [b" 1"], + "/latch.1": [b" 0"], + "/latch.2": [b" 1"], + "/latch.3": [b" 0"], + "/latch.4": [b" 1"], + "/latch.5": [b" 0"], + "/latch.6": [b" 1"], + "/latch.7": [b" 0"], + "/sensed.0": [b" 1"], + "/sensed.1": [b" 0"], + "/sensed.2": [b" 0"], + "/sensed.3": [ProtocolError], + "/sensed.4": [b" 0"], + "/sensed.5": [b" 0"], + "/sensed.6": [b" 0"], + "/sensed.7": [b" 0"], + }, }, "30.111111111111": { - ATTR_INJECT_READS: [ - b"DS2760", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - { - ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_INJECT_READS: b" 173.7563", - }, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2760"], + "/temperature": [b" 26.984"], + "/typeK/temperature": [b" 173.7563"], + "/volt": [b" 2.97"], + "/vis": [b" 0.12"], + }, }, "3A.111111111111": { - ATTR_INJECT_READS: [ - b"DS2413", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2413"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "3B.111111111111": { - ATTR_INJECT_READS: [ - b"DS1825", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 28.243"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1825"], + "/temperature": [b" 28.243"], + }, }, "42.111111111111": { - ATTR_INJECT_READS: [ - b"DS28EA00", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 29.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS28EA00"], + "/temperature": [b" 29.123"], + }, }, "A6.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "EF.111111111111": { - ATTR_INJECT_READS: [ - b"HobbyBoards_EF", # read type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 67.745"}, - {ATTR_INJECT_READS: b" 65.541"}, - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HobbyBoards_EF"], + "/humidity/humidity_corrected": [b" 67.745"], + "/humidity/humidity_raw": [b" 65.541"], + "/humidity/temperature": [b" 25.123"], + }, }, "EF.111111111112": { - ATTR_INJECT_READS: [ - b"HB_MOISTURE_METER", # read type - b" 1", # read is_leaf_0 - b" 1", # read is_leaf_1 - b" 0", # read is_leaf_2 - b" 0", # read is_leaf_3 - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 41.745"}, - {ATTR_INJECT_READS: b" 42.541"}, - {ATTR_INJECT_READS: b" 43.123"}, - {ATTR_INJECT_READS: b" 44.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_MOISTURE_METER"], + "/moisture/is_leaf.0": [b" 1"], + "/moisture/is_leaf.1": [b" 1"], + "/moisture/is_leaf.2": [b" 0"], + "/moisture/is_leaf.3": [b" 0"], + "/moisture/sensor.0": [b" 41.745"], + "/moisture/sensor.1": [b" 42.541"], + "/moisture/sensor.2": [b" 43.123"], + "/moisture/sensor.3": [b" 44.123"], + "/moisture/is_moisture.0": [b" 1"], + "/moisture/is_moisture.1": [b" 1"], + "/moisture/is_moisture.2": [b" 0"], + "/moisture/is_moisture.3": [b" 0"], + }, }, "EF.111111111113": { - ATTR_INJECT_READS: [ - b"HB_HUB", # read type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_HUB"], + "/hub/branch.0": [b" 1"], + "/hub/branch.1": [b" 0"], + "/hub/branch.2": [b" 1"], + "/hub/branch.3": [b" 0"], + "/hub/short.0": [b" 1"], + "/hub/short.1": [b" 0"], + "/hub/short.2": [b" 1"], + "/hub/short.3": [b" 0"], + }, }, "7E.111111111111": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0068", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 41.375"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0068"], + "/EDS0068/temperature": [b" 13.9375"], + "/EDS0068/pressure": [b" 1012.21"], + "/EDS0068/light": [b" 65.8839"], + "/EDS0068/humidity": [b" 41.375"], + }, }, "7E.222222222222": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0066", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0066"], + "/EDS0066/temperature": [b" 13.9375"], + "/EDS0066/pressure": [b" 1012.21"], + }, }, } diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 450cc4c7486c66..d94eda5b7c30fb 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,1645 +1,773 @@ # serializer version: 1 -# name: test_binary_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[10.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - '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': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - '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': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_binary_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.A', - 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.B', - 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_binary_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[1D.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_binary_sensors[1F.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[22.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[26.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[28.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[28.222222222222].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222222].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[28.222222222223].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222223].2 - list([ - ]) -# --- -# name: test_binary_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - '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': 'Sensed 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.7', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_binary_sensors[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.0', - 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.1', - 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.2', - 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.3', - 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.4', - 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.5', - 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.6', - 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.7', - 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_binary_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[30.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[30.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - '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': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - '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': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_binary_sensors[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.A', - 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.B', - 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_binary_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[3B.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[3B.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[42.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[7E.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[7E.222222222222].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222].2 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[A6.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[EF.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[EF.111111111112].1 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111112].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_binary_sensors[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_binary_sensors[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.0', - 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.1', - 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.2', - 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.3', - 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + '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': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.A', + 'friendly_name': '12.111111111111 Sensed A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + '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': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.B', + 'friendly_name': '12.111111111111 Sensed B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + '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': 'Sensed 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.0', + 'friendly_name': '29.111111111111 Sensed 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.1', + 'friendly_name': '29.111111111111 Sensed 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.2', + 'friendly_name': '29.111111111111 Sensed 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.3', + 'friendly_name': '29.111111111111 Sensed 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.4', + 'friendly_name': '29.111111111111 Sensed 4', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.5', + 'friendly_name': '29.111111111111 Sensed 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.6', + 'friendly_name': '29.111111111111 Sensed 6', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.7', + 'friendly_name': '29.111111111111 Sensed 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + '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': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.A', + 'friendly_name': '3A.111111111111 Sensed A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + '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': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.B', + 'friendly_name': '3A.111111111111 Sensed B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.0', + 'friendly_name': 'EF.111111111113 Hub short on branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.1', + 'friendly_name': 'EF.111111111113 Hub short on branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.2', + 'friendly_name': 'EF.111111111113 Hub short on branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.3', + 'friendly_name': 'EF.111111111113 Hub short on branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index f51fca7e988389..6c5631331ca320 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -12,7 +12,9 @@ ]), 'manufacturer': 'Hobby Boards', 'model': 'HB_HUB', + 'model_id': 'HB_HUB', 'name': 'EF.111111111113', + 'serial_number': '111111111113', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr new file mode 100644 index 00000000000000..159f3acea42bd2 --- /dev/null +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_registry[05.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'model_id': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[10.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'model_id': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[12.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'model_id': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[1D.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'model_id': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_registry[1F.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'model_id': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[22.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'model_id': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[26.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222223-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222223', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[29.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'model_id': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[30.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'model_id': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3A.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'model_id': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3B.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'model_id': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[42.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'model_id': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'model_id': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'model_id': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[A6.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'A6.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': 'A6.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'model_id': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111112-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'model_id': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111112', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111113-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'model_id': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111113', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr new file mode 100644 index 00000000000000..7c4027cd04613e --- /dev/null +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.28_111111111111_temperature_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '9', + '10', + '11', + '12', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature resolution', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tempres', + 'unique_id': '/28.111111111111/tempres', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.28_111111111111_temperature_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/28.111111111111/tempres', + 'friendly_name': '28.111111111111 Temperature resolution', + 'options': list([ + '9', + '10', + '11', + '12', + ]), + 'raw_value': 12.0, + }), + 'context': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 261b081060cc6d..1b8484b27a4dc5 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -1,3477 +1,2647 @@ # serializer version: 1 -# name: test_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[10.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.10_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/10.111111111111/temperature', +# name: test_sensors[sensor.10_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.10_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/10.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.10_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/10.111111111111/temperature', + 'friendly_name': '10.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[10.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/10.111111111111/temperature', - 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.10_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', - }), - ]) -# --- -# name: test_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/pressure', + 'context': , + 'entity_id': 'sensor.10_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.12_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.12_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/12.111111111111/TAI8570/pressure', + 'friendly_name': '12.111111111111 Pressure', + 'raw_value': 1025.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/12.111111111111/TAI8570/temperature', - 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/12.111111111111/TAI8570/pressure', - 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1025.1', - }), - ]) -# --- -# name: test_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - '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': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - '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': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_sensors[1F.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - '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': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - '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': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1F.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[22.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.22_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/22.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.12_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1025.123', + }) +# --- +# name: test_sensors[sensor.12_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.12_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/12.111111111111/TAI8570/temperature', + 'friendly_name': '12.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[22.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/22.111111111111/temperature', - 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.22_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) -# --- -# name: test_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.12_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_a', + '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': 'Counter A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251123', + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + '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': 'Counter B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '248125', + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.22_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/22.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/22.111111111111/temperature', + 'friendly_name': '22.111111111111 Temperature', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/humidity', + 'context': , + 'entity_id': 'sensor.22_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih3600_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': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/26.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH3600/humidity', + 'friendly_name': '26.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih3600_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': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/26.111111111111/HIH3600/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih4000_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': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/26.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH4000/humidity', + 'friendly_name': '26.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih4000_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': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/26.111111111111/HIH4000/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih5030_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': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/26.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH5030/humidity', + 'friendly_name': '26.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih5030_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': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/26.111111111111/HIH5030/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_htm1735_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': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/26.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HTM1735/humidity', + 'friendly_name': '26.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_htm1735_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': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/26.111111111111/HTM1735/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/humidity', + 'friendly_name': '26.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'context': , + 'entity_id': 'sensor.26_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/26.111111111111/S3-R1-A/illuminance', + 'friendly_name': '26.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) +# --- +# name: test_sensors[sensor.26_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/26.111111111111/B1-R1-A/pressure', + 'friendly_name': '26.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/S3-R1-A/illuminance', - 'unit_of_measurement': 'lx', + 'context': , + 'entity_id': 'sensor.26_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/26.111111111111/temperature', + 'friendly_name': '26.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/26.111111111111/VAD', + 'context': , + 'entity_id': 'sensor.26_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/26.111111111111/VAD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VAD', + 'friendly_name': '26.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/26.111111111111/VDD', + 'context': , + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/26.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VDD', + 'friendly_name': '26.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/26.111111111111/vis', + 'context': , + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/26.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/vis', + 'friendly_name': '26.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/26.111111111111/temperature', - 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/humidity', - 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH3600/humidity', - 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH4000/humidity', - 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH5030/humidity', - 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HTM1735/humidity', - 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/26.111111111111/B1-R1-A/pressure', - 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/26.111111111111/S3-R1-A/illuminance', - 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VAD', - 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VDD', - 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/vis', - 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) -# --- -# name: test_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.111111111111/temperature', + 'friendly_name': '28.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[28.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.111111111111/temperature', - 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222222_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222222/temperature', + 'context': , + 'entity_id': 'sensor.28_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222222_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222222/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222222/temperature9', + 'friendly_name': '28.222222222222 Temperature', + 'raw_value': 26.984, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[28.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222222/temperature9', - 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222223].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222223_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222223/temperature', + 'context': , + 'entity_id': 'sensor.28_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222223_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222223/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222223/temperature', + 'friendly_name': '28.222222222223 Temperature', + 'raw_value': 26.984, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[28.222222222223].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222223/temperature', - 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222223_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[29.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[29.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[30.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.28_222222222223_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/temperature', + 'friendly_name': '30.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_thermocouple_k_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': 'Thermocouple K temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermocouple_temperature_k', - 'unique_id': '/30.111111111111/typeX/temperature', + 'context': , + 'entity_id': 'sensor.30_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_thermocouple_k_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': 'Thermocouple K temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermocouple_temperature_k', + 'unique_id': '/30.111111111111/typeX/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/typeK/temperature', + 'friendly_name': '30.111111111111 Thermocouple K temperature', + 'raw_value': 173.7563, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/volt', + 'context': , + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.7563', + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage gradient', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis_gradient', + 'unique_id': '/30.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/vis', + 'friendly_name': '30.111111111111 VIS voltage gradient', + 'raw_value': 0.12, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage gradient', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis_gradient', - 'unique_id': '/30.111111111111/vis', + 'context': , + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/volt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/volt', + 'friendly_name': '30.111111111111 Voltage', + 'raw_value': 2.97, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[30.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/temperature', - 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/typeK/temperature', - 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '173.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/volt', - 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/vis', - 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) -# --- -# name: test_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3A.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[3A.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3B.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.3b_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/3B.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.30_111111111111_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3b_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/3B.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/3B.111111111111/temperature', + 'friendly_name': '3B.111111111111 Temperature', + 'raw_value': 28.243, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[3B.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/3B.111111111111/temperature', - 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.3b_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '28.2', - }), - ]) -# --- -# name: test_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[42.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.42_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/42.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.3b_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.243', + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.42_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/42.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/42.111111111111/temperature', + 'friendly_name': '42.111111111111 Temperature', + 'raw_value': 29.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[42.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/42.111111111111/temperature', - 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.42_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.1', - }), - ]) -# --- -# name: test_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/pressure', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.42_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.123', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/7E.111111111111/EDS0068/humidity', + 'friendly_name': '7E.111111111111 Humidity', + 'raw_value': 41.375, + 'state_class': , + 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/light', + 'context': , + 'entity_id': 'sensor.7e_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.375', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/7E.111111111111/EDS0068/light', + 'friendly_name': '7E.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/humidity', - 'unit_of_measurement': '%', + 'context': , + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.111111111111/EDS0068/pressure', + 'friendly_name': '7E.111111111111 Pressure', + 'raw_value': 1012.21, + 'state_class': , + 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[7E.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.111111111111/EDS0068/temperature', - 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.111111111111/EDS0068/pressure', - 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/7E.111111111111/EDS0068/light', - 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/7E.111111111111/EDS0068/humidity', - 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.4', - }), - ]) -# --- -# name: test_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/temperature', + 'context': , + 'entity_id': 'sensor.7e_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.111111111111/EDS0068/temperature', + 'friendly_name': '7E.111111111111 Temperature', + 'raw_value': 13.9375, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'context': , + 'entity_id': 'sensor.7e_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.222222222222/EDS0066/pressure', + 'friendly_name': '7E.222222222222 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[7E.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.222222222222/EDS0066/temperature', - 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.222222222222/EDS0066/pressure', - 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', - }), - ]) -# --- -# name: test_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/temperature', + 'context': , + 'entity_id': 'sensor.7e_222222222222_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.222222222222/EDS0066/temperature', + 'friendly_name': '7E.222222222222 Temperature', + 'raw_value': 13.9375, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/humidity', + 'context': , + 'entity_id': 'sensor.7e_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih3600_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': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/A6.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH3600/humidity', + 'friendly_name': 'A6.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih3600_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': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/A6.111111111111/HIH3600/humidity', + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih4000_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': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/A6.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH4000/humidity', + 'friendly_name': 'A6.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih4000_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': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/A6.111111111111/HIH4000/humidity', + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih5030_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': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/A6.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH5030/humidity', + 'friendly_name': 'A6.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih5030_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': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/A6.111111111111/HIH5030/humidity', + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_htm1735_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': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/A6.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HTM1735/humidity', + 'friendly_name': 'A6.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_htm1735_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': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/A6.111111111111/HTM1735/humidity', + 'context': , + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/humidity', + 'friendly_name': 'A6.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'context': , + 'entity_id': 'sensor.a6_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/A6.111111111111/S3-R1-A/illuminance', + 'friendly_name': 'A6.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/A6.111111111111/B1-R1-A/pressure', + 'friendly_name': 'A6.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', - 'unit_of_measurement': 'lx', + 'context': , + 'entity_id': 'sensor.a6_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/A6.111111111111/temperature', + 'friendly_name': 'A6.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/A6.111111111111/VAD', + 'context': , + 'entity_id': 'sensor.a6_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/A6.111111111111/VAD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VAD', + 'friendly_name': 'A6.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/A6.111111111111/VDD', + 'context': , + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/A6.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VDD', + 'friendly_name': 'A6.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/A6.111111111111/vis', + 'context': , + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/A6.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/vis', + 'friendly_name': 'A6.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/A6.111111111111/temperature', - 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/humidity', - 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH3600/humidity', - 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH4000/humidity', - 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH5030/humidity', - 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HTM1735/humidity', - 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/A6.111111111111/B1-R1-A/pressure', - 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/A6.111111111111/S3-R1-A/illuminance', - 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VAD', - 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VDD', - 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/vis', - 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) -# --- -# name: test_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[EF.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + 'context': , + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_corrected', + 'friendly_name': 'EF.111111111111 Humidity', + 'raw_value': 67.745, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_raw_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': 'Raw humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_raw', - 'unique_id': '/EF.111111111111/humidity/humidity_raw', + 'context': , + 'entity_id': 'sensor.ef_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.745', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_raw_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': 'Raw humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_raw', + 'unique_id': '/EF.111111111111/humidity/humidity_raw', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_raw', + 'friendly_name': 'EF.111111111111 Raw humidity', + 'raw_value': 65.541, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_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': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/temperature', + 'context': , + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.541', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_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': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/EF.111111111111/humidity/temperature', + 'friendly_name': 'EF.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[EF.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_corrected', - 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '67.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_raw', - 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.5', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/EF.111111111111/humidity/temperature', - 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', - }), - ]) -# --- -# name: test_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.0', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.1', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'context': , + 'entity_id': 'sensor.ef_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.2', + 'friendly_name': 'EF.111111111112 Moisture 2', + 'raw_value': 43.123, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.3', + 'friendly_name': 'EF.111111111112 Moisture 3', + 'raw_value': 44.123, + 'state_class': , 'unit_of_measurement': , }), - ]) -# --- -# name: test_sensors[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.0', - 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.1', - 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42.5', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.2', - 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '43.1', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.3', - 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '44.1', - }), - ]) -# --- -# name: test_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[EF.111111111113].1 - list([ - ]) -# --- -# name: test_sensors[EF.111111111113].2 - list([ - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_wetness_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_wetness_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.0', + 'friendly_name': 'EF.111111111112 Wetness 0', + 'raw_value': 41.745, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.745', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_wetness_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_wetness_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.1', + 'friendly_name': 'EF.111111111112 Wetness 1', + 'raw_value': 42.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.541', + }) # --- diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 3bc7a2d3def747..cb752982becb14 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -1,2565 +1,1777 @@ # serializer version: 1 -# name: test_switches[00.111111111111] - list([ - ]) -# --- -# name: test_switches[00.111111111111].1 - list([ - ]) -# --- -# name: test_switches[00.111111111111].2 - list([ - ]) -# --- -# name: test_switches[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.05_111111111111_programmed_input_output', - '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': 'Programmed input-output', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio', - 'unique_id': '/05.111111111111/PIO', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/05.111111111111/PIO', - 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[10.111111111111].1 - list([ - ]) -# --- -# name: test_switches[10.111111111111].2 - list([ - ]) -# --- -# name: test_switches[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - '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': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - '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': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_a', - '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': 'Latch A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_b', - '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': 'Latch B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.A', - 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.B', - 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.A', - 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.B', - 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[16.111111111111] - list([ - ]) -# --- -# name: test_switches[16.111111111111].1 - list([ - ]) -# --- -# name: test_switches[16.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[1D.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1D.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_switches[1F.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1F.111111111111].2 - list([ - ]) -# --- -# name: test_switches[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[22.111111111111].1 - list([ - ]) -# --- -# name: test_switches[22.111111111111].2 - list([ - ]) -# --- -# name: test_switches[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/26.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/26.111111111111/IAD', - 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.111111111111].1 - list([ - ]) -# --- -# name: test_switches[28.111111111111].2 - list([ - ]) -# --- -# name: test_switches[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222222].1 - list([ - ]) -# --- -# name: test_switches[28.222222222222].2 - list([ - ]) -# --- -# name: test_switches[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222223].1 - list([ - ]) -# --- -# name: test_switches[28.222222222223].2 - list([ - ]) -# --- -# name: test_switches[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - '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': 'Programmed input-output 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.7', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_0', - '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': 'Latch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.7', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.0', - 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.1', - 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.2', - 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.3', - 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.4', - 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.5', - 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.6', - 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.7', - 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.0', - 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.1', - 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.2', - 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.3', - 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.4', - 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.5', - 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.6', - 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.7', - 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[30.111111111111].1 - list([ - ]) -# --- -# name: test_switches[30.111111111111].2 - list([ - ]) -# --- -# name: test_switches[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - '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': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - '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': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.A', - 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.B', - 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3B.111111111111].1 - list([ - ]) -# --- -# name: test_switches[3B.111111111111].2 - list([ - ]) -# --- -# name: test_switches[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[42.111111111111].1 - list([ - ]) -# --- -# name: test_switches[42.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.111111111111].1 - list([ - ]) -# --- -# name: test_switches[7E.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.222222222222].1 - list([ - ]) -# --- -# name: test_switches[7E.222222222222].2 - list([ - ]) -# --- -# name: test_switches[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/A6.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/A6.111111111111/IAD', - 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111111].1 - list([ - ]) -# --- -# name: test_switches[EF.111111111111].2 - list([ - ]) -# --- -# name: test_switches[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - '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': 'Leaf sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - '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': 'Moisture sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.0', - 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.1', - 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.2', - 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.3', - 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.0', - 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.1', - 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.2', - 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.3', - 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - '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': 'Hub branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.0', - 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.1', - 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.2', - 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.3', - 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) +# name: test_switches[switch.05_111111111111_programmed_input_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.05_111111111111_programmed_input_output', + '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': 'Programmed input-output', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio', + 'unique_id': '/05.111111111111/PIO', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.05_111111111111_programmed_input_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/PIO', + 'friendly_name': '05.111111111111 Programmed input-output', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_a', + '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': 'Latch A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.A', + 'friendly_name': '12.111111111111 Latch A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_b', + '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': 'Latch B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.B', + 'friendly_name': '12.111111111111 Latch B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + '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': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.A', + 'friendly_name': '12.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + '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': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.B', + 'friendly_name': '12.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/26.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/26.111111111111/IAD', + 'friendly_name': '26.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_0', + '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': 'Latch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.0', + 'friendly_name': '29.111111111111 Latch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.1', + 'friendly_name': '29.111111111111 Latch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.2', + 'friendly_name': '29.111111111111 Latch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.3', + 'friendly_name': '29.111111111111 Latch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.4', + 'friendly_name': '29.111111111111 Latch 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.5', + 'friendly_name': '29.111111111111 Latch 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.6', + 'friendly_name': '29.111111111111 Latch 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.7', + 'friendly_name': '29.111111111111 Latch 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + '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': 'Programmed input-output 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.0', + 'friendly_name': '29.111111111111 Programmed input-output 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.1', + 'friendly_name': '29.111111111111 Programmed input-output 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.2', + 'friendly_name': '29.111111111111 Programmed input-output 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.3', + 'friendly_name': '29.111111111111 Programmed input-output 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.4', + 'friendly_name': '29.111111111111 Programmed input-output 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.5', + 'friendly_name': '29.111111111111 Programmed input-output 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.6', + 'friendly_name': '29.111111111111 Programmed input-output 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.7', + 'friendly_name': '29.111111111111 Programmed input-output 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + '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': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.A', + 'friendly_name': '3A.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + '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': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.B', + 'friendly_name': '3A.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/A6.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/A6.111111111111/IAD', + 'friendly_name': 'A6.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + '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': 'Leaf sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.0', + 'friendly_name': 'EF.111111111112 Leaf sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.1', + 'friendly_name': 'EF.111111111112 Leaf sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.2', + 'friendly_name': 'EF.111111111112 Leaf sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.3', + 'friendly_name': 'EF.111111111112 Leaf sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + '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': 'Moisture sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.0', + 'friendly_name': 'EF.111111111112 Moisture sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.1', + 'friendly_name': 'EF.111111111112 Moisture sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.2', + 'friendly_name': 'EF.111111111112 Moisture sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.3', + 'friendly_name': 'EF.111111111112 Moisture sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + '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': 'Hub branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.0', + 'friendly_name': 'EF.111111111113 Hub branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.1', + 'friendly_name': 'EF.111111111113 Hub branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.2', + 'friendly_name': 'EF.111111111113 Hub branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.3', + 'friendly_name': 'EF.111111111113 Hub branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 31895f705ff3cd..dd2f3874e3627b 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -3,57 +3,65 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.BINARY_SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire binary sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + """Test for 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["29.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 8 ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 029e1278c868e6..65bdaafc1311dc 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for 1-Wire config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -11,18 +12,39 @@ INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +_HASSIO_DISCOVERY = HassioServiceInfo( + config={"host": "1302b8e0-owserver", "port": 4304, "addon": "owserver (1-wire)"}, + name="owserver (1-wire)", + slug="1302b8e0_owserver", + uuid="e3fa56560d93458b96a594cbcea3017e", +) +_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("5.6.7.8"), + ip_addresses=[ip_address("5.6.7.8")], + hostname="ubuntu.local.", + name="OWFS (1-wire) Server", + port=4304, + type="_owserver._tcp.local.", + properties={}, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture async def filled_device_registry( - hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" for key in ("28.111111111111", "28.222222222222", "28.222222222223"): @@ -36,13 +58,31 @@ async def filled_device_registry( return device_registry -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] + + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + + +async def test_user_flow_recovery(hass: HomeAssistant) -> None: + """Test user flow recovery after invalid server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) # Invalid server with patch( @@ -54,9 +94,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} # Valid server with patch( @@ -67,19 +107,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} async def test_user_duplicate( - hass: HomeAssistant, config_entry: ConfigEntry, mock_setup_entry: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test user duplicate flow.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -102,9 +137,185 @@ async def test_user_duplicate( assert result["reason"] == "already_configured" +async def test_reconfigure_flow( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure flow.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + # Invalid server + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + + # Valid server + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_duplicate( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure duplicate flow.""" + other_config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOST: "2.3.4.5", + CONF_PORT: 2345, + }, + entry_id="other", + ) + other_config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + # Duplicate server + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + assert config_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + assert other_config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} + + +async def test_hassio_flow(hass: HomeAssistant) -> None: + """Test HassIO discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "owserver (1-wire)" + assert new_entry.data == {CONF_HOST: "1302b8e0-owserver", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_hassio_duplicate(hass: HomeAssistant) -> None: + """Test HassIO discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test zeroconf discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "OWFS (1-wire) Server" + assert new_entry.data == {CONF_HOST: "ubuntu.local.", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: + """Test zeroconf discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test clearing the options.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -127,8 +338,8 @@ async def test_user_options_clear( @pytest.mark.usefixtures("filled_device_registry") -async def test_user_options_empty_selection( - hass: HomeAssistant, config_entry: ConfigEntry +async def test_user_options_empty_selection_recovery( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test leaving the selection of devices empty.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -141,7 +352,7 @@ async def test_user_options_empty_selection( "28.222222222223": False, } - # Verify that an empty selection does not modify the options + # Verify that an empty selection shows the form again result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={INPUT_ENTRY_DEVICE_SELECTION: []}, @@ -150,10 +361,29 @@ async def test_user_options_empty_selection( assert result["step_id"] == "device_selection" assert result["errors"] == {"base": "device_not_selected"} + # Verify that a single selected device to configure comes back as a form with the device to configure + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={INPUT_ENTRY_DEVICE_SELECTION: ["28.111111111111"]}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"]["sensor_id"] == "28.111111111111" + + # Verify that the setting for the device comes back as default when no input is given + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"]["device_options"]["28.111111111111"]["precision"] + == "temperature" + ) + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_set_single( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test configuring a single device.""" # Clear config options to certify functionality when starting from scratch @@ -191,7 +421,7 @@ async def test_user_options_set_single( async def test_user_options_set_multiple( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, filled_device_registry: dr.DeviceRegistry, ) -> None: """Test configuring multiple consecutive devices in a row.""" @@ -254,7 +484,7 @@ async def test_user_options_set_multiple( async def test_user_options_no_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that options does not change when no devices are available.""" assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index ecdae859597b55..60b57bd14f7102 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -6,12 +6,12 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,35 +19,21 @@ @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield -DEVICE_DETAILS = { - "device_info": { - "identifiers": [["onewire", "EF.111111111113"]], - "manufacturer": "Hobby Boards", - "model": "HB_HUB", - "name": "EF.111111111113", - }, - "family": "EF", - "id": "EF.111111111113", - "path": "/EF.111111111113/", - "type": "HB_HUB", -} - - @pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) async def test_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 82ff75628c2928..0748481c40bd8a 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -3,23 +3,30 @@ from copy import deepcopy from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyownet import protocol import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("owproxy_with_connerror") -async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_connect_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test connection failure raises ConfigEntryNotReady.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -29,7 +36,7 @@ async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) - async def test_listing_failure( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" owproxy.return_value.dir.side_effect = protocol.OwnetError() @@ -42,7 +49,7 @@ async def test_listing_failure( @pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test being able to unload an entry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -57,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def test_update_options( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test update options triggers reload.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -77,11 +84,56 @@ async def test_update_options( assert owproxy.call_count == 2 -@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries + for device_entry in device_entries: + assert device_entry == snapshot(name=f"{device_entry.name}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, ["1F.111111111111"]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + ) + + +@patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: @@ -93,12 +145,12 @@ async def test_registry_cleanup( dead_id = "28.111111111111" # Initialise with two components - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + setup_owproxy_mock_devices(owproxy, [live_id, dead_id]) await hass.config_entries.async_setup(entry_id) await hass.async_block_till_done() # Reload with a device no longer on bus - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + setup_owproxy_mock_devices(owproxy, [live_id]) await hass.config_entries.async_reload(entry_id) await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 diff --git a/tests/components/onewire/test_select.py b/tests/components/onewire/test_select.py new file mode 100644 index 00000000000000..6e1c3277c73a71 --- /dev/null +++ b/tests/components/onewire/test_select.py @@ -0,0 +1,96 @@ +"""Tests for 1-Wire selects.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +async def test_selection_option_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, +) -> None: + """Test for 1-Wire select option service.""" + setup_owproxy_mock_devices(owproxy, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + + entity_id = "select.28_111111111111_temperature_resolution" + assert hass.states.get(entity_id).state == "12" + + # Test SELECT_OPTION service + owproxy.return_value.read.side_effect = [b" 9"] + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "9"}, + blocking=True, + ) + assert hass.states.get(entity_id).state == "9" diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index ba0e21701f8f78..f1ef2dfa11bdbb 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,68 +5,75 @@ import logging from unittest.mock import MagicMock, _patch_dict, patch +from freezegun.api import FrozenDateTimeFactory from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + """Test for 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.parametrize("device_id", ["12.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 2 + ) @pytest.mark.parametrize("device_id", ["12.111111111111"]) async def test_tai8570_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, @@ -78,11 +85,11 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS].append(OwnetError) - mock_device[ATTR_INJECT_READS].append(OwnetError) + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) with caplog.at_level(logging.DEBUG): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 936e83f66ec230..ca13a69e2dac4a 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -3,11 +3,12 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -16,66 +17,73 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switches( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire switches.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + """Test for 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.parametrize("device_id", ["05.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) @pytest.mark.parametrize("device_id", ["05.111111111111"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_toggle( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, ) -> None: """Test for 1-Wire switch TOGGLE service.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index f619127d9b9c0e..865bc1a6bbff1b 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( @@ -18,6 +17,10 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from . import ( create_config_entry_from_info, @@ -95,9 +98,9 @@ async def test_ssdp_discovery_already_configured( ) config_entry.add_to_hass(hass) - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", @@ -232,9 +235,9 @@ async def test_ssdp_discovery_success( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with valid host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", @@ -261,9 +264,9 @@ async def test_ssdp_discovery_success( async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -286,9 +289,9 @@ async def test_ssdp_discovery_host_none_info( hass: HomeAssistant, stub_mock_discovery ) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -307,9 +310,9 @@ async def test_ssdp_discovery_no_location( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no location.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location=None, - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -328,9 +331,9 @@ async def test_ssdp_discovery_no_host( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 5c01fb2d2009bb..0bad7050fd9334 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,13 +6,13 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( HOST, @@ -44,10 +44,10 @@ "MAC": "ff:ee:dd:cc:bb:aa", }, ] -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="any", ip="5.6.7.8", macaddress=MAC.lower().replace(":", "") ) -DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_SAME_IP = DhcpServiceInfo( hostname="any", ip="1.2.3.4", macaddress=MAC.lower().replace(":", "") ) diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 209e7cbccef7fd..16172112c11374 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -119,7 +119,83 @@ async def test_line_detector_crossed(hass: HomeAssistant) -> None: ) -async def test_tapo_vehicle(hass: HomeAssistant) -> None: +async def test_tapo_line_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/LineCross.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyLineCrossDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsLineCross", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule" + ) + + +async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" event = await get_event( { @@ -198,7 +274,83 @@ async def test_tapo_vehicle(hass: HomeAssistant) -> None: ) -async def test_tapo_person(hass: HomeAssistant) -> None: +async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" event = await get_event( { @@ -274,6 +426,234 @@ async def test_tapo_person(hass: HomeAssistant) -> None: ) +async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/People", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tamper(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTamperDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsTamper", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Tamper Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "tamper" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule" + ) + + +async def test_tapo_intrusion(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.100.155:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.100.155:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyIntrusionDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Intrusion Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "safety" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule" + ) + + async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" event = await get_event( diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index eaa3a9de64c876..4ef8b8655ee14b 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'conversation_id': 'my-conversation-id', 'response': IntentResponse( card=dict({ }), diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index e0665bc449f71d..9ee19cd330c18c 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,6 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from freezegun import freeze_time from httpx import Response @@ -12,7 +12,6 @@ Function, ) from openai.types.completion_usage import CompletionUsage -from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -22,7 +21,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid from tests.common import MockConfigEntry @@ -57,7 +55,7 @@ async def test_entity( async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: - """Test that the default prompt works.""" + """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, @@ -73,82 +71,6 @@ async def test_error_handling( assert result.response.error_code == "unknown", result -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = Mock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - ), - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert ( - "The user name is Test User." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - assert ( - "The user id is 12345." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - - async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -504,65 +426,3 @@ async def test_assist_api_tools_conversion( tools = mock_create.mock_calls[0][2]["tools"] assert tools - - -async def test_unknown_hass_api( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - mock_init_component, -) -> None: - """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "non-existing", - }, - ) - - await hass.async_block_till_done() - - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result == snapshot - - -@patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, -) -async def test_conversation_id( - mock_create, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id=mock_config_entry.entry_id - ) - - conversation_id = result.conversation_id - - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == conversation_id - - unknown_id = ulid.ulid() - - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id != unknown_id - - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == "koala" diff --git a/tests/components/openhome/test_config_flow.py b/tests/components/openhome/test_config_flow.py index 7ab1e69106ccbf..6430b8610e9087 100644 --- a/tests/components/openhome/test_config_flow.py +++ b/tests/components/openhome/test_config_flow.py @@ -1,12 +1,15 @@ """Tests for the Openhome config flow module.""" -from homeassistant.components import ssdp from homeassistant.components.openhome.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -14,7 +17,7 @@ MOCK_FRIENDLY_NAME = "Test Client" MOCK_SSDP_LOCATION = "http://device:12345/description.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -60,7 +63,7 @@ async def test_device_exists(hass: HomeAssistant) -> None: async def test_missing_udn(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -79,7 +82,7 @@ async def test_missing_udn(hass: HomeAssistant) -> None: async def test_missing_ssdp_location(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location="", diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index cd02c14e4eb520..d14fbc5cbd1c13 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -830,7 +830,9 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id( # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -861,7 +863,9 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -897,7 +901,9 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Setup the config entry config_entry = MockConfigEntry( - data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + data={ + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}" + }, domain=otbr.DOMAIN, options={}, source="hassio", @@ -914,7 +920,7 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Make sure the data of the existing entry was not updated expected_data = { - "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}", } config_entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert config_entry.data == expected_data diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index cef5ef350a93b2..711cc6c1d86e58 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -17,11 +17,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.overkiz.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -742,7 +742,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", @@ -801,7 +801,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index b05d1d0cb87a35..9ae6be407ec3c1 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -7,6 +7,7 @@ from python_overseerr import MovieDetails, RequestCount, RequestResponse from python_overseerr.models import TVDetails, WebhookNotificationConfig +from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL from homeassistant.components.overseerr.const import DOMAIN from homeassistant.const import ( CONF_API_KEY, @@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]: yield client +@pytest.fixture +def mock_overseerr_client_needs_change( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.types = 0 + return mock_overseerr_client + + +@pytest.fixture +def mock_overseerr_client_cloudhook( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD" + return mock_overseerr_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" @@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry: }, entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", ) + + +@pytest.fixture +def mock_cloudhook_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index a4d07d6e9d3cec..40028e1f80f545 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"event\":\"{{event}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdbId\":\"{{media_tmdbid}}\",\"tvdbId\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requestedBy_email\":\"{{requestedBy_email}}\",\"requestedBy_username\":\"{{requestedBy_username}}\",\"requestedBy_avatar\":\"{{requestedBy_avatar}}\",\"requestedBy_settings_discordId\":\"{{requestedBy_settings_discordId}}\",\"requestedBy_settings_telegramChatId\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reportedBy_email\":\"{{reportedBy_email}}\",\"reportedBy_username\":\"{{reportedBy_username}}\",\"reportedBy_avatar\":\"{{reportedBy_avatar}}\",\"reportedBy_settings_discordId\":\"{{reportedBy_settings_discordId}}\",\"reportedBy_settings_telegramChatId\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commentedBy_email\":\"{{commentedBy_email}}\",\"commentedBy_username\":\"{{commentedBy_username}}\",\"commentedBy_avatar\":\"{{commentedBy_avatar}}\",\"commentedBy_settings_discordId\":\"{{commentedBy_settings_discordId}}\",\"commentedBy_settings_telegramChatId\":\"{{commentedBy_settings_telegramChatId}}\"},\"{{extra}}\":[]\n}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{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_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json index cc8795c9821f1d..75059bcaf9664b 100644 --- a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -1,25 +1,23 @@ { "notification_type": "MEDIA_AUTO_APPROVED", - "event": "Movie Request Automatically Approved", "subject": "Something (2024)", "message": "Here is an interesting Linux ISO that was automatically approved.", "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg", "media": { "media_type": "movie", - "tmdbId": "123", - "tvdbId": "", + "tmdb_id": "123", + "tvdb_id": "", "status": "PENDING", "status4k": "UNKNOWN" }, "request": { "request_id": "16", - "requestedBy_email": "my@email.com", - "requestedBy_username": "henk", - "requestedBy_avatar": "https://plex.tv/users/abc/avatar?c=123", - "requestedBy_settings_discordId": "123", - "requestedBy_settings_telegramChatId": "" + "requested_by_email": "my@email.com", + "requested_by_username": "henk", + "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_settings_discord_id": "123", + "requested_by_settings_telegram_chat_id": "" }, "issue": null, - "comment": null, - "extra": [] + "comment": null } diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr new file mode 100644 index 00000000000000..9bf23efb8f698d --- /dev/null +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_entities[event.overseerr_last_media_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.overseerr_last_media_event', + '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': 'Last media event', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_media_event', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.overseerr_last_media_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'comment': None, + 'event_type': 'auto_approved', + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + 'friendly_name': 'Overseerr Last media event', + 'image': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', + 'issue': None, + 'media': dict({ + 'media_type': 'movie', + 'status': 'PENDING', + 'status4k': 'UNKNOWN', + 'tmdb_id': '123', + 'tvdb_id': '', + }), + 'message': 'Here is an interesting Linux ISO that was automatically approved.', + 'notification_type': 'MEDIA_AUTO_APPROVED', + 'request': dict({ + 'request_id': '16', + 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', + 'requested_by_email': 'my@email.com', + 'requested_by_settings_discord_id': '123', + 'requested_by_settings_telegram_chat_id': '', + 'requested_by_username': 'henk', + }), + 'subject': 'Something (2024)', + }), + 'context': , + 'entity_id': 'event.overseerr_last_media_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py new file mode 100644 index 00000000000000..7ad6b53c7edcf3 --- /dev/null +++ b/tests/components/overseerr/test_event.py @@ -0,0 +1,109 @@ +"""Tests for the Overseerr event platform.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from future.backports.datetime import timedelta +import pytest +from python_overseerr import OverseerrConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_webhook, setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-10-21") +async def test_event_does_not_write_state( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities don't write state on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + +async def test_event_goes_unavailable( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities go unavailable when we can't fetch data.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.overseerr_last_media_event").state != STATE_UNAVAILABLE + ) + + mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError( + "Boom" + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 0962cd2c2f1193..4c6897ed3167d3 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -1,13 +1,18 @@ """Tests for the Overseerr integration.""" from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion -from homeassistant.components.overseerr import JSON_PAYLOAD, REGISTERED_NOTIFICATIONS +from homeassistant.components import cloud +from homeassistant.components.overseerr import ( + CONF_CLOUDHOOK_URL, + JSON_PAYLOAD, + REGISTERED_NOTIFICATIONS, +) from homeassistant.components.overseerr.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -15,6 +20,7 @@ from . import setup_integration from tests.common import MockConfigEntry +from tests.components.cloud import mock_cloud async def test_device_info( @@ -150,3 +156,192 @@ async def test_prefer_internal_ip( mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0] == "https://www.example.com/api/webhook/test-webhook-id" ) + + +async def test_cloudhook_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_cloudhook_consistent( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_cloudhook_needs_no_change( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_cloudhook: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls) + == 0 + ) + + +async def test_cloudhook_not_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we prefer local webhook over cloudhook.""" + + await hass.async_block_till_done() + + with ( + patch.object(cloud, "async_active_subscription", return_value=True), + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert CONF_CLOUDHOOK_URL not in mock_config_entry.data + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 1 + ) + assert ( + mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[ + 0 + ][0][0] + == "http://10.10.10.10:8123/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_not_connecting( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test the cloudhook is not registered if Overseerr cannot connect to it.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index fad535df914e9e..d3694653cd4fa3 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -79,7 +79,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.target_temperature_max = 50 mock_client.pellet_quantity = 1248 mock_client.pellet_level = 0 + mock_client.has_second_fan = True + mock_client.has_second_fan = False mock_client.fan_speed = 3 + mock_client.current_fan_speed.return_value = 3 + mock_client.min_fan_speed.return_value = 0 + mock_client.max_fan_speed.return_value = 5 mock_client.connect.return_value = True mock_client.update_state.return_value = True mock_client.set_on.return_value = True diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr new file mode 100644 index 00000000000000..6827c9a1f2289d --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.stove_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.stove_silent', + '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': 'Silent', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'silent', + 'unique_id': '11:22:33:44:55:66-silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.stove_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stove Silent', + }), + 'context': , + 'entity_id': 'button.stove_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index e7cea3749a1cef..aa637039df9795 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -6,7 +6,6 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'silent', '1', '2', '3', @@ -56,7 +55,6 @@ 'current_temperature': 18, 'fan_mode': '3', 'fan_modes': list([ - 'silent', '1', '2', '3', diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr index 0a25a1cfa8bebe..7ace1149e0a662 100644 --- a/tests/components/palazzetti/snapshots/test_number.ambr +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -55,3 +55,115 @@ 'state': '3', }) # --- +# name: test_all_entities[number.stove_left_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_left_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Left fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_left_speed', + 'unique_id': '11:22:33:44:55:66-fan_left_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_left_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Left fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_left_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_right_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Right fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_right_speed', + 'unique_id': '11:22:33:44:55:66-fan_right_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Right fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_right_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py new file mode 100644 index 00000000000000..de0f26fe8aa27d --- /dev/null +++ b/tests/components/palazzetti/test_button.py @@ -0,0 +1,69 @@ +"""Tests for the Palazzetti button platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +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, snapshot_platform + +ENTITY_ID = "button.stove_silent" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_press( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing via service call.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_palazzetti_client.set_fan_silent.assert_called_once() + + +async def test_async_press_error( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing with error via service call.""" + await setup_integration(hass, mock_config_entry) + + mock_palazzetti_client.set_fan_silent.side_effect = CommunicationError() + error_message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 78af8f00bdb9c0..22bd04f234eacc 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -15,7 +15,7 @@ SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH, FAN_SILENT +from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -118,15 +118,6 @@ async def test_async_set_data( ) # Set Fan Mode: Success - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_SILENT}, - blocking=True, - ) - mock_palazzetti_client.set_fan_silent.assert_called_once() - mock_palazzetti_client.set_fan_silent.reset_mock() - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 03c56c33d0ce34..8550f1a3de01cb 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,12 +4,12 @@ from pypalazzetti.exceptions import CommunicationError -from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST 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 @@ -101,7 +101,7 @@ async def test_dhcp_flow( """Test the DHCP flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, @@ -130,7 +130,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 939c7c72c19b74..8f09384c1b7b80 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType import pytest from syrupy import SnapshotAssertion @@ -16,7 +17,8 @@ from tests.common import MockConfigEntry, snapshot_platform -ENTITY_ID = "number.stove_combustion_power" +POWER_ENTITY_ID = "number.stove_combustion_power" +FAN_ENTITY_ID = "number.stove_left_fan_speed" async def test_all_entities( @@ -33,7 +35,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_async_set_data( +async def test_async_set_data_power( hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -45,28 +47,71 @@ async def test_async_set_data( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, blocking=True, ) mock_palazzetti_client.set_power_mode.assert_called_once_with(1) - mock_palazzetti_client.set_on.reset_mock() + mock_palazzetti_client.set_power_mode.reset_mock() # Set value: Error mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() - with pytest.raises(HomeAssistantError): + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, blocking=True, ) - mock_palazzetti_client.set_on.reset_mock() + mock_palazzetti_client.set_power_mode.reset_mock() mock_palazzetti_client.set_power_mode.side_effect = ValidationError() - with pytest.raises(ServiceValidationError): + message = "Combustion power 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + + +async def test_async_set_data_fan( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_fan_speed.assert_called_once_with(1, FanType.LEFT) + mock_palazzetti_client.set_on.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_fan_speed.side_effect = CommunicationError() + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_on.reset_mock() + + mock_palazzetti_client.set_fan_speed.side_effect = ValidationError() + message = "Fan left speed 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, blocking=True, ) diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index a97e8d3b564fac..9f0806f0591f83 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -6,12 +6,12 @@ from peblar import PeblarAuthenticationError, PeblarConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -232,7 +232,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -273,7 +273,7 @@ async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -308,7 +308,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -362,7 +362,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -389,7 +389,7 @@ async def test_user_flow_with_zeroconf_in_progress(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c4ec108bb6bf8c..42dcf449168f0a 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -523,7 +523,7 @@ async def test_callback_view( assert result["type"] is FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() - forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' + forward_url = f"{config_flow.AUTH_CALLBACK_PATH}?flow_id={result['flow_id']}" resp = await client.get(forward_url) assert resp.status == HTTPStatus.OK diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index e0ada8ea849820..92ed42aa03a53e 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -30,6 +30,24 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def chosen_env(request: pytest.FixtureRequest) -> str: + """Pass the chosen_env string. + + Used with fixtures that require parametrization of the user-data fixture. + """ + return request.param + + +@pytest.fixture +def gateway_id(request: pytest.FixtureRequest) -> str: + """Pass the gateway_id string. + + Used with fixtures that require parametrization of the gateway_id. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -76,7 +94,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - + all_data = _read_json(chosen_env, "all_data") with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -97,7 +115,6 @@ def mock_smile_adam() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -106,42 +123,18 @@ def mock_smile_adam() -> Generator[MagicMock]: @pytest.fixture -def mock_smile_adam_2() -> Generator[MagicMock]: - """Create a 2nd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_heating" - +def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: + """Create a special base Mock Adam type for testing with different datasets.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) - - yield smile - - -@pytest.fixture -def mock_smile_adam_3() -> Generator[MagicMock]: - """Create a 3rd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_cooling" - - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - + smile.connect.return_value = Version("3.6.4") smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" smile.smile_version = "3.6.4" @@ -150,20 +143,15 @@ def mock_smile_adam_3() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_adam_4() -> Generator[MagicMock]: - """Create a 4th Mock Adam environment for testing exceptions.""" +def mock_smile_adam_jip() -> Generator[MagicMock]: + """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -178,7 +166,6 @@ def mock_smile_adam_4() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.2.8") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -187,66 +174,18 @@ def mock_smile_adam_4() -> Generator[MagicMock]: @pytest.fixture -def mock_smile_anna() -> Generator[MagicMock]: - """Create a Mock Anna environment for testing exceptions.""" - chosen_env = "anna_heatpump_heating" +def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: + """Create a Mock Anna type for testing.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_2() -> Generator[MagicMock]: - """Create a 2nd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_cooling" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_3() -> Generator[MagicMock]: - """Create a 3rd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_idle" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" smile.smile_version = "4.0.15" @@ -255,63 +194,31 @@ def mock_smile_anna_3() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_p1() -> Generator[MagicMock]: - """Create a Mock P1 DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_single" +def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: + """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "a455b61e52394b2db5081ce025a430f3" - smile.heater_id = None - smile.smile_version = "4.4.2" - smile.smile_type = "power" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) - - yield smile - - -@pytest.fixture -def mock_smile_p1_2() -> Generator[MagicMock]: - """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_triple" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "03e65b16e4b247a29ae0d75a78cb492e" + smile.connect.return_value = Version("4.4.2") + smile.gateway_id = gateway_id smile.heater_id = None - smile.smile_version = "4.4.2" - smile.smile_type = "power" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "power" + smile.smile_version = "4.4.2" yield smile @@ -320,6 +227,7 @@ def mock_smile_p1_2() -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -334,7 +242,6 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Smile Anna" smile.connect.return_value = Version("1.8.22") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -346,6 +253,7 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -360,7 +268,6 @@ def mock_stretch() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Stretch" smile.connect.return_value = Version("3.1.11") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 5c0e3fbdd2e1d3..554326a72b1d34 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -9,32 +11,30 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize( + ("entity_id", "expected_state"), + [ + ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), + ("binary_sensor.opentherm_dhw_state", STATE_OFF), + ("binary_sensor.opentherm_heating", STATE_ON), + ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), + ("binary_sensor.opentherm_compressor_state", STATE_ON), + ], +) async def test_anna_climate_binary_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, + entity_id: str, + expected_state: str, ) -> None: """Test creation of climate related binary_sensor entities.""" - - state = hass.states.get("binary_sensor.opentherm_secondary_boiler_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_dhw_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_heating") - assert state - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.opentherm_cooling_enabled") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_compressor_state") - assert state - assert state.state == STATE_ON + state = hass.states.get(entity_id) + assert state.state == expected_state +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -66,8 +66,12 @@ async def test_adam_climate_binary_sensor_change( assert not state.attributes.get("other_msg") -async def test_p1_v4_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +async def test_p1_binary_sensor_entity( + hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: """Test of a Smile P1 related plugwise-notification binary_sensor.""" state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 8368af8e5ccdbc..ab6bd3d4f29d16 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -79,8 +79,11 @@ async def test_adam_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of adam climate device environment.""" state = hass.states.get("climate.living_room") @@ -104,9 +107,10 @@ async def test_adam_2_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_adam_3: MagicMock, + mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: @@ -120,7 +124,7 @@ async def test_adam_3_climate_entity_attributes( HVACMode.AUTO, HVACMode.COOL, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "heating" ) @@ -148,7 +152,7 @@ async def test_adam_3_climate_entity_attributes( HVACMode.HEAT, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" ) @@ -266,7 +270,7 @@ async def test_adam_climate_entity_climate_changes( async def test_adam_climate_off_mode_change( hass: HomeAssistant, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in adam climate device environment.""" @@ -282,9 +286,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 1 - mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 1 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("heating") state = hass.states.get("climate.kinderkamer") assert state @@ -298,9 +302,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 - mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("off") state = hass.states.get("climate.logeerkamer") assert state @@ -314,10 +318,11 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -343,9 +348,10 @@ async def test_anna_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_2: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -362,9 +368,10 @@ async def test_anna_2_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_3: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -378,6 +385,7 @@ async def test_anna_3_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 1f30fc972bb85e..16af7065c49a7c 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -13,7 +13,6 @@ import pytest from homeassistant.components.plugwise.const import DEFAULT_PORT, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -25,6 +24,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 014003d29d016d..874c4b61a471c9 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -61,6 +61,7 @@ } +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -80,6 +81,7 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -109,6 +111,10 @@ async def test_gateway_config_entry_not_ready( assert mock_config_entry.state is entry_state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_device_in_dr( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -131,6 +137,7 @@ async def test_device_in_dr( assert device_entry.sw_version == "4.4.2" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -224,16 +231,17 @@ async def test_migrate_unique_id_relay( assert entity_migrated.unique_id == new_unique_id +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smile_adam_2: MagicMock, + mock_smile_adam_heat_cool: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test a clean-up of the device_registry.""" - data = mock_smile_adam_2.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index fdceb0426695b3..c5361433388e9a 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -16,6 +16,7 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -25,6 +26,7 @@ async def test_anna_number_entities( assert float(state.state) == 60.0 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -45,19 +47,17 @@ async def test_anna_max_boiler_temp_change( ) -async def test_adam_number_entities( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: - """Test creation of a number.""" + """Test changing of number entities.""" state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") assert state assert float(state.state) == 60.0 - -async def test_adam_dhw_setpoint_change( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -68,8 +68,8 @@ async def test_adam_dhw_setpoint_change( blocking=True, ) - assert mock_smile_adam_2.set_number.call_count == 1 - mock_smile_adam_2.set_number.assert_called_with( + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 8891a88bb91146..f06d07767f3b7f 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -50,8 +50,11 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_select_regulation_mode( - hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test a regulation_mode select. @@ -73,8 +76,8 @@ async def test_adam_select_regulation_mode( }, blocking=True, ) - assert mock_smile_adam_3.set_select.call_count == 1 - mock_smile_adam_3.set_select.assert_called_with( + assert mock_smile_adam_heat_cool.set_select.call_count == 1 + mock_smile_adam_heat_cool.set_select.assert_called_with( "select_regulation_mode", "bc93488efab249e5bc54fd7e175a6f91", "heating", @@ -91,6 +94,7 @@ async def test_legacy_anna_select_entities( assert not hass.states.get("select.anna_thermostat_schedule") +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_adam_select_unavailable_regulation_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index f10f3f00933d2b..b3243d6b1274af 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -41,7 +41,9 @@ async def test_adam_climate_sensor_entities( async def test_adam_climate_sensor_entity_2( - hass: HomeAssistant, mock_smile_adam_4: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of climate related sensor entities.""" state = hass.states.get("sensor.woonkamer_humidity") @@ -52,7 +54,7 @@ async def test_adam_climate_sensor_entity_2( async def test_unique_id_migration_humidity( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" @@ -92,6 +94,7 @@ async def test_unique_id_migration_humidity( assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -113,6 +116,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert float(state.state) == 86.0 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_p1_dsmr_sensor_entities( hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -137,11 +144,15 @@ async def test_p1_dsmr_sensor_entities( assert not state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of power related sensor entities.""" @@ -163,10 +174,14 @@ async def test_p1_3ph_dsmr_sensor_entities( assert float(state.state) == 233.2 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) async def test_p1_3ph_dsmr_sensor_disabled_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index bd1e3cfac293dc..ea003af86c76cd 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], diff --git a/tests/components/powerfox/conftest.py b/tests/components/powerfox/conftest.py index 14ccc5996e5504..1d930394254322 100644 --- a/tests/components/powerfox/conftest.py +++ b/tests/components/powerfox/conftest.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from powerfox import Device, DeviceType, PowerMeter, WaterMeter +from powerfox import Device, DeviceType, HeatMeter, PowerMeter, WaterMeter import pytest from homeassistant.components.powerfox.const import DOMAIN @@ -53,6 +53,14 @@ def mock_powerfox_client() -> Generator[AsyncMock]: type=DeviceType.COLD_WATER_METER, name="Wateropti", ), + Device( + id="9x9x1f12xx5x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=False, + bidirectional=False, + type=DeviceType.HEAT_METER, + name="Heatopti", + ), ] client.device.side_effect = [ PowerMeter( @@ -70,6 +78,14 @@ def mock_powerfox_client() -> Generator[AsyncMock]: cold_water=1111.111, warm_water=0.0, ), + HeatMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + total_energy=1111.111, + delta_energy=111, + total_volume=1111.111, + delta_volume=0.111, + ), ] yield client diff --git a/tests/components/powerfox/snapshots/test_diagnostics.ambr b/tests/components/powerfox/snapshots/test_diagnostics.ambr index 781e7b8c0d542e..d749a1b5b60e9d 100644 --- a/tests/components/powerfox/snapshots/test_diagnostics.ambr +++ b/tests/components/powerfox/snapshots/test_diagnostics.ambr @@ -21,6 +21,16 @@ 'warm_water': 0.0, }), }), + dict({ + 'heat_meter': dict({ + 'delta_energy': 111, + 'delta_volume': 0.111, + 'outdated': False, + 'timestamp': '2024-11-26 10:48:51', + 'total_energy': 1111.111, + 'total_volume': 1111.111, + }), + }), ]), }) # --- diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr index dda162d4eeb67a..a2aa8a9c72cc1f 100644 --- a/tests/components/powerfox/snapshots/test_sensor.ambr +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -1,4 +1,205 @@ # serializer version: 1 +# name: test_all_sensors[sensor.heatopti_delta_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_delta_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Delta energy', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_delta_energy', + 'unique_id': '9x9x1f12xx5x_heat_delta_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heatopti Delta energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_delta_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_delta_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Delta volume', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_delta_volume', + 'unique_id': '9x9x1f12xx5x_heat_delta_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Heatopti Delta volume', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_delta_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_total_energy', + 'unique_id': '9x9x1f12xx5x_heat_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heatopti Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_total_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total volume', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_total_volume', + 'unique_id': '9x9x1f12xx5x_heat_total_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Heatopti Total volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_total_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- # name: test_all_sensors[sensor.poweropti_energy_return-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py index a38f316faf3b00..377ae9c622c89a 100644 --- a/tests/components/powerfox/test_config_flow.py +++ b/tests/components/powerfox/test_config_flow.py @@ -5,18 +5,18 @@ from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.powerfox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_DIRECT_HOST from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="powerfox.local", diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 1ff1470f81c03e..cd4f1250aa4574 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -11,12 +11,12 @@ ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.powerwall.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo import homeassistant.util.dt as dt_util from .mocks import ( @@ -161,7 +161,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -188,7 +188,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -230,7 +230,7 @@ async def test_dhcp_discovery_manual_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -272,7 +272,7 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -316,7 +316,7 @@ async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -394,7 +394,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -431,7 +431,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -468,7 +468,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_successful( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -503,7 +503,7 @@ async def test_dhcp_discovery_updates_unique_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -542,7 +542,7 @@ async def test_dhcp_discovery_updates_unique_id_when_entry_is_failed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -581,7 +581,7 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -630,7 +630,7 @@ async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 043a9cc438937b..bbd58619b127f9 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -541,8 +541,7 @@ async def test_view_empty_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( @@ -569,8 +568,7 @@ async def test_view_default_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( diff --git a/tests/components/prusalink/test_binary_sensor.py b/tests/components/prusalink/test_binary_sensor.py index c39b15471c6d6a..474a4e265d1509 100644 --- a/tests/components/prusalink/test_binary_sensor.py +++ b/tests/components/prusalink/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test Prusalink sensors.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest @@ -12,16 +12,13 @@ @pytest.fixture(autouse=True) def setup_binary_sensor_platform_only(): """Only setup sensor platform.""" - with ( - patch("homeassistant.components.prusalink.PLATFORMS", [Platform.BINARY_SENSOR]), - patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ), + with patch( + "homeassistant.components.prusalink.PLATFORMS", [Platform.BINARY_SENSOR] ): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors_no_job( hass: HomeAssistant, mock_config_entry, mock_api ) -> None: diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index c0693626600020..ead56f6493dac6 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -1,7 +1,7 @@ """Test Prusalink sensors.""" from datetime import UTC, datetime -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest @@ -27,16 +27,11 @@ @pytest.fixture(autouse=True) def setup_sensor_platform_only(): """Only setup sensor platform.""" - with ( - patch("homeassistant.components.prusalink.PLATFORMS", [Platform.SENSOR]), - patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ), - ): + with patch("homeassistant.components.prusalink.PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) -> None: """Test sensors while no job active.""" assert await async_setup_component(hass, "prusalink", {}) @@ -140,6 +135,7 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_idle_job_mk3( hass: HomeAssistant, mock_config_entry, @@ -248,6 +244,7 @@ async def test_sensors_idle_job_mk3( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry, diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index d14f367b2bd6a2..12edb7a9c6e9ae 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import location @@ -80,8 +80,6 @@ MOCK_DATA_VERSION_1 = {CONF_TOKEN: MOCK_CREDS, "devices": [MOCK_DEVICE_VERSION_1]} -MOCK_DEVICE_ID = "somedeviceid" - MOCK_ENTRY_VERSION_1 = MockConfigEntry( domain=DOMAIN, data=MOCK_DATA_VERSION_1, entry_id=MOCK_ENTRY_ID, version=1 ) @@ -141,20 +139,26 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: async def test_config_flow_entry_migrate( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that config flow entry is migrated correctly.""" # Start with the config entry at Version 1. manager = hass.config_entries mock_entry = MOCK_ENTRY_VERSION_1 mock_entry.add_to_manager(manager) + mock_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) mock_entity_id = f"media_player.ps4_{MOCK_UNIQUE_ID}" mock_e_entry = entity_registry.async_get_or_create( "media_player", "ps4", MOCK_UNIQUE_ID, config_entry=mock_entry, - device_id=MOCK_DEVICE_ID, + device_id=mock_device_entry.id, ) assert len(entity_registry.entities) == 1 assert mock_e_entry.entity_id == mock_entity_id @@ -180,7 +184,7 @@ async def test_config_flow_entry_migrate( # Test that entity_id remains the same. assert mock_entity.entity_id == mock_entity_id - assert mock_entity.device_id == MOCK_DEVICE_ID + assert mock_entity.device_id == mock_device_entry.id # Test that last four of credentials is appended to the unique_id. assert mock_entity.unique_id == f"{MOCK_UNIQUE_ID}_{MOCK_CREDS[-4:]}" diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 4305dab2236e38..96704e900dd471 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -5,12 +5,12 @@ from gridnet import GridNetConnectionError -from homeassistant.components import zeroconf from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_full_user_flow_implementation( @@ -48,7 +48,7 @@ async def test_full_zeroconf_flow_implementationn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -104,7 +104,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 2d151b4b81e6ac..14229e836628e6 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -711,4 +711,4 @@ async def test_no_other_imports_allowed( source = "import sys" hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done(wait_background_tasks=True) - assert "Error executing script: Not allowed to import sys" in caplog.text + assert "ImportError: Not allowed to import sys" in caplog.text diff --git a/tests/components/qbus/__init__.py b/tests/components/qbus/__init__.py new file mode 100644 index 00000000000000..e8c002d1ed9ef2 --- /dev/null +++ b/tests/components/qbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the Qbus integration.""" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py new file mode 100644 index 00000000000000..8268d091bda727 --- /dev/null +++ b/tests/components/qbus/conftest.py @@ -0,0 +1,33 @@ +"""Test fixtures for qbus.""" + +import pytest + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import FIXTURE_PAYLOAD_CONFIG + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="000001", + data={ + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def payload_config() -> JsonObjectType: + """Return the config topic payload.""" + return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) diff --git a/tests/components/qbus/const.py b/tests/components/qbus/const.py new file mode 100644 index 00000000000000..408ef59d5b1486 --- /dev/null +++ b/tests/components/qbus/const.py @@ -0,0 +1,4 @@ +"""Define const for unit tests.""" + +FIXTURE_PAYLOAD_CONFIG = "payload_config.json" +TOPIC_CONFIG = "cloudapp/QBUSMQTTGW/config" diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json new file mode 100644 index 00000000000000..2ee38a9927ebaf --- /dev/null +++ b/tests/components/qbus/fixtures/payload_config.json @@ -0,0 +1,49 @@ +{ + "app": "abc", + "devices": [ + { + "id": "UL1", + "ip": "192.168.1.123", + "mac": "001122334455", + "name": "", + "serialNr": "000001", + "type": "Qbus", + "version": "3.14.0", + "properties": { + "connectable": { + "read": true, + "type": "boolean", + "write": false + }, + "connected": { + "read": true, + "type": "boolean", + "write": false + } + }, + "functionBlocks": [ + { + "id": "UL10", + "location": "Living", + "locationId": 0, + "name": "LIVING", + "originalName": "LIVING", + "refId": "000001/10", + "type": "onoff", + "variant": [null], + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "read": true, + "type": "boolean", + "write": true + } + } + } + ] + } + ] +} diff --git a/tests/components/qbus/test_config_flow.py b/tests/components/qbus/test_config_flow.py new file mode 100644 index 00000000000000..4f94f2bb27709c --- /dev/null +++ b/tests/components/qbus/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test config flow.""" + +import json +import time +from unittest.mock import patch + +import pytest +from qbusmqttapi.discovery import QbusDiscovery + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.coordinator import QbusConfigCoordinator +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +_PAYLOAD_DEVICE_STATE = '{"id":"UL1","properties":{"connected":true},"type":"event"}' + + +async def test_step_discovery_confirm_create_entry( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test mqtt confirm step and entry creation.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + } + assert result.get("result").unique_id == "000001" + + +@pytest.mark.parametrize( + ("topic", "payload"), + [ + ("cloudapp/QBUSMQTTGW/state", b""), + ("invalid/topic", b"{}"), + ], +) +async def test_step_mqtt_invalid( + hass: HomeAssistant, topic: str, payload: bytes +) -> None: + """Test mqtt discovery with empty payload.""" + discovery = MqttServiceInfo( + subscribed_topic=topic, + topic=topic, + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +@pytest.mark.parametrize( + ("payload", "mqtt_publish"), + [ + ('{ "online": true }', True), + ('{ "online": false }', False), + ], +) +async def test_handle_gateway_topic_when_online( + hass: HomeAssistant, payload: str, mqtt_publish: bool +) -> None: + """Test handling of gateway topic with payload indicating online.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/state", + topic="cloudapp/QBUSMQTTGW/state", + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called is mqtt_publish + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_config_topic( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of config topic.""" + + discovery = MqttServiceInfo( + subscribed_topic=TOPIC_CONFIG, + topic=TOPIC_CONFIG, + payload=json.dumps(payload_config), + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_device_topic_missing_config(hass: HomeAssistant) -> None: + """Test handling of device topic when config is missing.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_handle_device_topic_device_not_found( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of device topic when device is not found.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL2/state", + payload='{"id":"UL2","properties":{"connected":true},"type":"event"}', + qos=0, + retain=False, + timestamp=time.time(), + ) + + with patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_step_user_not_supported(hass: HomeAssistant) -> None: + """Test user step, which should abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "not_supported" diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py new file mode 100644 index 00000000000000..83bb667e4eb7bc --- /dev/null +++ b/tests/components/qbus/test_switch.py @@ -0,0 +1,84 @@ +"""Test Qbus switch entities.""" + +import json + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +_PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' +_PAYLOAD_SWITCH_STATE_OFF = '{"id":"UL10","properties":{"value":false},"type":"state"}' +_PAYLOAD_SWITCH_SET_STATE_ON = ( + '{"id": "UL10", "type": "state", "properties": {"value": true}}' +) +_PAYLOAD_SWITCH_SET_STATE_OFF = ( + '{"id": "UL10", "type": "state", "properties": {"value": false}}' +) + +_TOPIC_SWITCH_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/state" +_TOPIC_SWITCH_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/setState" + +_SWITCH_ENTITY_ID = "switch.living" + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_ON + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 94e80d3cd16ff2..f09cf7493b5a49 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -6,19 +6,19 @@ from aioqsw.exceptions import LoginError, QswError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="qsw-m408-4c", ip="192.168.1.200", macaddress="245ebe000000", diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py index 7f9479339a5994..db4f4de6c49fed 100644 --- a/tests/components/rabbitair/test_config_flow.py +++ b/tests/components/rabbitair/test_config_flow.py @@ -10,12 +10,12 @@ from rabbitair import Mode, Model, Speed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rabbitair.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "1.1.1.1" TEST_NAME = "abcdef1234_123456789012345678" @@ -26,7 +26,7 @@ TEST_UNIQUE_ID = format_mac(TEST_MAC) TEST_TITLE = "Rabbit Air" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], port=9009, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 586b31b092f0ab..6448d46a8a1170 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rachio.const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, @@ -13,6 +12,10 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -120,13 +123,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -145,13 +148,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -171,13 +174,13 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index a188f8fcb7087a..a84f3870357958 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -6,11 +6,11 @@ from radiotherm.validate import RadiothermTstatError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.radiotherm.const import DOMAIN from homeassistant.const import CONF_HOST 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 @@ -112,7 +112,7 @@ async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -156,7 +156,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -185,7 +185,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py index 7e75440c30dad8..320299d2e60eea 100644 --- a/tests/components/rainforest_raven/const.py +++ b/tests/components/rainforest_raven/const.py @@ -13,8 +13,9 @@ from iso4217 import Currency from homeassistant.components import usb +from homeassistant.helpers.service_info.usb import UsbServiceInfo -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device="/dev/ttyACM0", pid="0x0003", vid="0x04B4", diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5838dcc35c845f..cd8b2cb39c8ea8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -7,7 +7,6 @@ from regenmaschine.errors import RainMachineError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, @@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: @@ -168,7 +168,7 @@ async def test_step_homekit_zeroconf_ip_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -196,7 +196,7 @@ async def test_step_homekit_zeroconf_ip_change( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.2"), ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", @@ -225,7 +225,7 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -279,7 +279,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -299,7 +299,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index d7ca35c9341c45..522bd6ea367ef5 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -347,11 +347,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index adb71dffb9ee80..026227f68a054c 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -360,11 +360,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index c0d607b12a74d6..770d25c9cf2e04 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -479,11 +479,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index f60b7b49df493a..8cf3e16e5a829e 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -469,11 +469,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 4cc1074de413b3..2ba62ba78f5f22 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -593,11 +593,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index d989cacb76a5e3..3b7c4a300c2c65 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -529,12 +529,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 8c984b61f6c01e..4d7f893de25310 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -694,12 +694,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 74d8861ae1e9a4..24070e6f156316 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2655,9 +2655,9 @@ async def test_setup_fails_after_downgrade( await hass.async_stop() assert instance.engine is None assert ( - f"The database schema version {SCHEMA_VERSION+1} is newer than {SCHEMA_VERSION}" - " which is the maximum database schema version supported by the installed " - "version of Home Assistant Core" + f"The database schema version {SCHEMA_VERSION + 1} is newer " + f"than {SCHEMA_VERSION} which is the maximum database schema " + "version supported by the installed version of Home Assistant Core" ) in caplog.text diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index aeeeba1865aaed..4e6d664ec0acfb 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -35,7 +35,6 @@ from homeassistant.components.recorder.util import ( MIN_VERSION_SQLITE, RETRYABLE_MYSQL_ERRORS, - UPCOMING_MIN_VERSION_SQLITE, database_job_retry_wrapper, end_incomplete_runs, is_second_sunday, @@ -236,7 +235,7 @@ def _make_cursor_mock(*_): @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: """Test setting up the connection for a sqlite dialect.""" @@ -289,7 +288,7 @@ def _make_cursor_mock(*_): @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite_zero_commit_interval( sqlite_version: str, @@ -510,11 +509,11 @@ def _make_cursor_mock(*_): [ ( "3.30.0", - "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ( "2.0.0", - "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ], ) @@ -552,8 +551,8 @@ def _make_cursor_mock(*_): @pytest.mark.parametrize( "sqlite_version", [ - ("3.31.0"), - ("3.33.0"), + ("3.40.1"), + ("3.41.0"), ], ) def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> None: @@ -734,63 +733,6 @@ def _make_cursor_mock(*_): assert database_engine.optimizer.slow_range_in_select is False -async def test_issue_for_old_sqlite( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create and delete an issue for old sqlite versions.""" - instance_mock = MagicMock() - instance_mock.hass = hass - execute_args = [] - close_mock = MagicMock() - min_version = str(MIN_VERSION_SQLITE) - - def execute_mock(statement): - nonlocal execute_args - execute_args.append(statement) - - def fetchall_mock(): - nonlocal execute_args - if execute_args[-1] == "SELECT sqlite_version()": - return [[min_version]] - return None - - def _make_cursor_mock(*_): - return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) - - dbapi_connection = MagicMock(cursor=_make_cursor_mock) - - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is not None - assert issue.translation_placeholders == { - "min_version": str(UPCOMING_MIN_VERSION_SQLITE), - "server_version": min_version, - } - - min_version = str(UPCOMING_MIN_VERSION_SQLITE) - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is None - assert database_engine is not None - - @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_basic_sanity_check( diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 59342934c1c161..5950fc49966a1b 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -15,7 +15,6 @@ ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN @@ -32,6 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, @@ -381,7 +381,7 @@ async def test_reauth_abort_unique_id_mismatch( async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -451,7 +451,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -548,7 +548,7 @@ async def test_dhcp_ip_update( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -620,7 +620,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c6bf7b51213e6d..2e7c155620117a 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -29,38 +29,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed -async def test_cleanup_hdr_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_connect: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test cleanup of the HDR switch entity.""" - original_id = f"{TEST_UID}_hdr" - domain = Platform.SWITCH - - reolink_connect.channels = [0] - reolink_connect.supported.return_value = True - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None - - @pytest.mark.parametrize( ( "original_id", @@ -132,41 +100,6 @@ def mock_supported(ch, cap): reolink_connect.supported.return_value = True -async def test_hdr_switch_deprecated_repair_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_connect: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repairs issue is raised when hdr switch entity used.""" - original_id = f"{TEST_UID}_hdr" - domain = Platform.SWITCH - - reolink_connect.channels = [0] - reolink_connect.supported.return_value = True - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=None, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - assert (DOMAIN, "hdr_switch_deprecated") in issue_registry.issues - - @pytest.mark.parametrize( ( "original_id", diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 1eb184950bcae7..c994cc59c5d696 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -22,7 +22,7 @@ TEST_DAY2 = 15 TEST_HOUR = 13 TEST_MINUTE = 12 -TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4" +TEST_FILE_NAME_MP4 = f"Mp4Record/{TEST_YEAR}-{TEST_MONTH}-{TEST_DAY}/RecS04_{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00_123456_AB123C.mp4" TEST_STREAM = "sub" TEST_CHANNEL = "0" TEST_VOD_TYPE = VodRequestType.PLAYBACK.value diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 8f212b6e9764ab..79736d418b53de 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -58,17 +58,17 @@ async def test_one_pt2262(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await rfxtrx.signal("0913000022670e013970") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "on" await rfxtrx.signal("09130000226707013d70") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "off" @@ -85,10 +85,10 @@ async def test_pt2262_unconfigured(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226707") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226707" state = hass.states.get("binary_sensor.pt2262_226707") assert state @@ -318,7 +318,7 @@ async def test_pt2262_duplicate_id(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf9820a6..6fd4fc14bc56b6 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) @@ -758,10 +756,10 @@ async def test_options_add_and_configure_device( assert entry.data["devices"]["0913000022670e013970"] assert entry.data["devices"]["0913000022670e013970"]["off_delay"] == 9 - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 4336798768ff51..f17fd8743f1b6a 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -330,10 +330,10 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "unknown" - assert state.attributes.get("friendly_name") == "PT2262 22670e Signal strength" + assert state.attributes.get("friendly_name") == "PT2262 226700 Signal strength" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -351,7 +351,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b70") await rfxtrx.signal("0b1100cd0213c7f230010f71") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-64" @@ -362,7 +362,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b60") await rfxtrx.signal("0b1100cd0213c7f230010f61") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-72" diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 7acc008cc8aeea..964c5ccb2e6006 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -70,23 +70,23 @@ async def test_one_pt2262_switch(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_on", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_off", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "off" assert rfxtrx.transport.send.mock_calls == [ @@ -220,26 +220,26 @@ async def test_pt2262_switch_events(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" # "Command: 0xE" await rfxtrx.signal("0913000022670e013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x0" await rfxtrx.signal("09130000226700013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x7" await rfxtrx.signal("09130000226707013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" # "Command: 0x1" await rfxtrx.signal("09130000226701013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" async def test_discover_switch(hass: HomeAssistant, rfxtrx_automatic) -> None: diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 409cdac55aaa81..778bad67d779d7 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -6,12 +6,12 @@ import ring_doorbell from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import MOCK_HARDWARE_ID @@ -269,9 +269,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -302,9 +300,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 27d4813f02d1b4..7c3b93e5114b89 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -444,6 +444,7 @@ async def test_no_listen_start( version=1, data={"username": "foo", "token": {}}, ) + mock_entry.add_to_hass(hass) # Create a binary sensor entity so it is not ignored by the deprecation check # and the listener will start entity_registry.async_get_or_create( @@ -457,7 +458,6 @@ async def test_no_listen_start( mock_ring_event_listener_class.return_value.started = False - mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 435e762a6461a8..d4d7376a56437b 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -46,6 +46,7 @@ async def test_entity_id_migration( ) -> None: """Test the migration of unique IDs on config entry setup.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + config_entry.add_to_hass(hass) # Pre-create old style unique IDs charging = entity_registry.async_get_or_create( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 357c644e2fe95e..d65bf7c61d72c5 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,5 +1,6 @@ """Global fixtures for Roborock integration.""" +from collections.abc import Generator from copy import deepcopy from unittest.mock import patch @@ -14,7 +15,7 @@ CONF_USER_DATA, DOMAIN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -161,18 +162,27 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USER_DATA: USER_DATA.as_dict(), CONF_BASE_URL: BASE_URL, }, + unique_id=USER_EMAIL, ) mock_entry.add_to_hass(hass) return mock_entry +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, -) -> MockConfigEntry: + platforms: list[Platform], +) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock_roborock_entry + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_roborock_entry diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index e70dac5ffc9182..0e4b338f469d9f 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test Roborock Binary Sensor.""" +import pytest + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BINARY_SENSOR] + + async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 43ef043f79c9fb..0a7efe83513ebd 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BUTTON] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 39d8117847c285..13bc23e6e2bf58 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -244,3 +244,28 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" + + +async def test_account_already_configured( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index c884baef123340..e240dccf7ebdef 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -5,10 +5,11 @@ from http import HTTPStatus from unittest.mock import patch +import pytest from roborock import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +20,12 @@ from tests.typing import ClientSessionGenerator +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.IMAGE] + + async def test_floorplan_image( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index cace9a8ed67d7a..f4f490e68d9dd3 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -4,7 +4,12 @@ from unittest.mock import patch import pytest -from roborock import RoborockException, RoborockInvalidCredentials +from roborock import ( + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -128,20 +133,18 @@ async def test_local_client_fails_props( assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY -async def test_fails_maps_continue( +async def test_fail_maps( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture_v1_only, ) -> None: - """Test that if we fail to get the maps, we still setup.""" + """Test that the integration fails to load if we fail to get the maps.""" with patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", side_effect=RoborockException(), ): await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.LOADED - # No map data means no images - assert len(hass.states.async_all("image")) == 0 + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY async def test_reauth_started( @@ -194,3 +197,35 @@ async def test_not_supported_a01_device( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert "The device you added is not yet supported" in caplog.text + + +async def test_invalid_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user agreement is out of date.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockInvalidUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + mock_roborock_entry.error_reason_translation_key == "invalid_user_agreement" + ) + + +async def test_no_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user has no agreement.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockNoUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 7e87b49253e683..bfd8cc6da2bedc 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.NUMBER] + + @pytest.mark.parametrize( ("entity_id", "value"), [ diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 784150e24c7c5c..7f25141306b8ef 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -7,16 +7,22 @@ from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN +from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .mock_data import PROP +from .mock_data import MULTI_MAP_LIST, PROP from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SELECT] + + @pytest.mark.parametrize( ("entity_id", "value"), [ @@ -86,3 +92,33 @@ async def test_none_map_select( await async_setup_component(hass, DOMAIN, {}) select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity.state == STATE_UNKNOWN + + +async def test_selected_map_name( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that the selected map is set to the correct map name.""" + await async_setup_component(hass, DOMAIN, {}) + select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") + assert select_entity.state == "Upstairs" + + +async def test_selected_map_without_name( + hass: HomeAssistant, + bypass_api_fixture_v1_only, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that maps without a name are given a placeholder name.""" + map_list = copy.deepcopy(MULTI_MAP_LIST) + map_list.map_info[0].name = "" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", + return_value=map_list, + ): + await async_setup_component(hass, DOMAIN, {}) + + select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") + assert select_entity + assert select_entity.state == "Map 0" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 908754f3b921b8..e33d3aa78d547e 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from roborock import DeviceData, HomeDataDevice from roborock.const import ( FILTER_REPLACE_TIME, @@ -12,6 +13,7 @@ from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.version_1_apis import RoborockMqttClientV1 +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .mock_data import CONSUMABLE, STATUS, USER_DATA @@ -19,9 +21,15 @@ from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 38 + assert len(hass.states.async_all("sensor")) == 40 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -46,6 +54,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert hass.states.get("sensor.roborock_s7_maxv_total_cleaning_count").state == "31" assert ( hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state == "2023-01-01T03:22:10+00:00" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 5de3c208c1e9de..2476bfe497c8b8 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SWITCH] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 836a86bd1147fc..eb48e8e537f12b 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -7,12 +7,19 @@ import roborock from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.TIME] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 449ba7bca954a0..d9d4340ec837c8 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -40,6 +40,15 @@ DEVICE_ID = "abc123" +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + # Note: Currently the Image platform is required to make these tests pass since + # some initialization of the coordinator happens as a side effect of loading + # image platform. Fix that and remove IMAGE here. + return [Platform.VACUUM, Platform.IMAGE] + + async def test_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index fe3ef215524de4..3165e5c4ba0eff 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -2,8 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import ssdp, zeroconf -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) NAME = "Roku 3" NAME_ROKUTV = '58" Onn Roku TV' @@ -13,7 +20,7 @@ UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -25,14 +32,14 @@ HOMEKIT_HOST = "192.168.1.161" -MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_HOMEKIT_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address(HOMEKIT_HOST), ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, properties={ - zeroconf.ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", + ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", }, type="mock_type", ) diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py index a29f899ee9df91..55d54f3a80b97a 100644 --- a/tests/components/romy/test_config_flow.py +++ b/tests/components/romy/test_config_flow.py @@ -6,11 +6,14 @@ from romy import RomyRobot from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.romy.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) def _create_mocked_romy( @@ -164,14 +167,14 @@ async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None assert result2["type"] is FlowResultType.CREATE_ENTRY -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=8080, hostname="aicu-aicgsbksisfapcjqmqjq.local", type="mock_type", name="myROMY", - properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, + properties={ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, ) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index dedccc14249582..5b6766f7eb9929 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from roombapy import RoombaConnectionError, RoombaInfo -from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import ( CONF_BLID, @@ -23,6 +22,8 @@ from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -32,7 +33,7 @@ DISCOVERY_DEVICES = [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="501479ddeeff", hostname="irobot-blid", @@ -40,7 +41,7 @@ ), ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="80a589ddeeff", hostname="roomba-blid", @@ -48,7 +49,7 @@ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", @@ -60,7 +61,7 @@ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", @@ -74,12 +75,12 @@ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="4.4.4.4", macaddress="50:14:79:DD:EE:FF", hostname="irobot-blid", ), - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="5.5.5.5", macaddress="80:A5:89:DD:EE:FF", hostname="roomba-blid", @@ -692,7 +693,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( @pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( hass: HomeAssistant, - discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], + discovery_data: tuple[str, DhcpServiceInfo | ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -910,7 +911,7 @@ async def test_dhcp_discovery_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -933,7 +934,7 @@ async def test_dhcp_discovery_already_configured_host(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -959,7 +960,7 @@ async def test_dhcp_discovery_already_configured_blid(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -985,7 +986,7 @@ async def test_dhcp_discovery_not_irobot(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="Notirobot-blid", @@ -1006,7 +1007,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -1023,7 +1024,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", @@ -1044,7 +1045,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-bl", @@ -1082,7 +1083,7 @@ async def test_dhcp_discovery_when_user_flow_in_progress(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index bf6884e09fb252..2516bd816505dd 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -78,6 +78,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.mute = AsyncMock() zone.unmute = AsyncMock() zone.toggle_mute = AsyncMock() + zone.set_seek_time = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 51cbb9772dcb9e..550ea9404df537 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -4,11 +4,11 @@ from unittest.mock import AsyncMock from homeassistant.components.russound_rio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_CONFIG, MOCK_RECONFIGURATION_CONFIG, MODEL diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 5a6420da0006aa..d0c18a9b1e7a13 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,6 +9,7 @@ from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, @@ -16,6 +17,7 @@ ) from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -232,3 +234,22 @@ async def test_power_service( await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, blocking=True) mock_russound_client.controllers[1].zones[1].zone_off.assert_called_once() + + +async def test_media_seek( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test media seek service.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_SEEK_POSITION: 100}, + ) + + mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( + 100 + ) diff --git a/tests/components/ruuvi_gateway/test_config_flow.py b/tests/components/ruuvi_gateway/test_config_flow.py index c4ecf929f94c69..14f74a7add7a42 100644 --- a/tests/components/ruuvi_gateway/test_config_flow.py +++ b/tests/components/ruuvi_gateway/test_config_flow.py @@ -6,10 +6,10 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ruuvi_gateway.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .consts import ( BASE_DATA, @@ -32,7 +32,7 @@ BASE_DATA, ), ( - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="RuuviGateway1234", ip=DHCP_IP, macaddress="1234567890ab", diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 1a7347ff0ce976..c1a9da4e28450a 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -2,18 +2,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT -from homeassistant.components import ssdp from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, METHOD_LEGACY, METHOD_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, -) from homeassistant.const import ( CONF_HOST, CONF_IP_ADDRESS, @@ -24,6 +17,13 @@ CONF_PORT, CONF_TOKEN, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_CONFIG = { CONF_HOST: "fake_host", @@ -61,7 +61,7 @@ CONF_TOKEN: "123456789", } -MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:schemas-upnp-org:service:RenderingControl:1", ssdp_location="https://fake_host:12345/test", @@ -72,7 +72,7 @@ ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:samsung.com:service:MainTVAgent2:1", ssdp_location="https://fake_host:12345/tv_agent", diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index eb78332b7b3c76..576a5f6d53490b 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -17,7 +17,6 @@ from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, @@ -33,13 +32,6 @@ TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, - SsdpServiceInfo, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -54,6 +46,15 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .const import ( @@ -83,7 +84,7 @@ CONF_PORT: 8002, } MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -94,7 +95,7 @@ ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -104,7 +105,7 @@ }, ) -MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -115,7 +116,7 @@ ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -126,11 +127,11 @@ ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" ) EXISTING_IP = "192.168.40.221" -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1704,7 +1705,7 @@ async def test_update_legacy_missing_mac_from_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) @@ -1741,7 +1742,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 8ca6bd4cb90845..5ce777a47fa515 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -12,7 +12,6 @@ ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, @@ -25,6 +24,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL 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 @@ -135,7 +135,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Pentair: 01-01-01", ip="1.1.1.1", macaddress="aabbccddeeff", diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index bc66503cf5ee0b..5bcfae0917e65d 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -81,6 +81,11 @@ ]), 'max_temp': 20, 'min_temp': 10, + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_modes': list([ 'stopped', 'fixedtop', @@ -109,7 +114,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'ABC999111', 'unit_of_measurement': None, @@ -137,7 +142,13 @@ ]), 'max_temp': 20, 'min_temp': 10, - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'stopped', + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_mode': 'stopped', 'swing_modes': list([ 'stopped', @@ -227,3 +238,23 @@ 'state': 'off', }) # --- +# name: test_climate_get_device_capabilities + dict({ + 'climate.hallway': dict({ + 'horizontalswing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + }), + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index bdafc8654ffc45..7438fb70140727 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -1,61 +1,4 @@ # serializer version: 1 -# name: test_select[load_platforms0][select.hallway_horizontal_swing-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.hallway_horizontal_swing', - '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': 'Horizontal swing', - 'platform': 'sensibo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'horizontalswing', - 'unique_id': 'ABC999111-horizontalSwing', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[load_platforms0][select.hallway_horizontal_swing-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Hallway Horizontal swing', - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'context': , - 'entity_id': 'select.hallway_horizontal_swing', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- # name: test_select[load_platforms0][select.hallway_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 089d9e107b05bb..7e848f3870c3b3 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -13,12 +13,14 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -43,6 +45,7 @@ SERVICE_ENABLE_PURE_BOOST, SERVICE_ENABLE_TIMER, SERVICE_FULL_STATE, + SERVICE_GET_DEVICE_CAPABILITIES, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN @@ -264,6 +267,95 @@ async def test_climate_swing( assert "swing_mode" not in state.attributes +async def test_climate_horizontal_swing( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Sensibo climate horizontal swing service.""" + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "stopped" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].horizontal_swing_modes = [ + "stopped", + "fixedleft", + "fixedcenter", + "fixedright", + "not_in_ha", + ] + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_modes_translated = { + "stopped": "stopped", + "fixedleft": "fixedLeft", + "fixedcenter": "fixedCenter", + "fixedright": "fixedRight", + "not_in_ha": "not_in_ha", + } + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="Climate horizontal swing mode not_in_ha is not supported by the integration", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "not_in_ha"}, + blocking=True, + ) + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "fixedleft"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "fixedleft" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "targetTemperature", + "swing", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match="service_not_supported"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_SWING_HORIZONTAL_MODE: "fixedcenter", + }, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert "swing_horizontal_mode" not in state.attributes + + async def test_climate_temperatures( hass: HomeAssistant, load_int: ConfigEntry, @@ -862,7 +954,7 @@ async def test_climate_climate_react( "light": "on", }, "lowTemperatureThreshold": 5.5, - "type": "temperature", + "type": "feelsLike", }, } @@ -893,7 +985,7 @@ async def test_climate_climate_react( "horizontalSwing": "stopped", "light": "on", }, - ATTR_SMART_TYPE: "temperature", + ATTR_SMART_TYPE: "feelslike", }, blocking=True, ) @@ -901,7 +993,7 @@ async def test_climate_climate_react( mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" - ].smart_type = "temperature" + ].smart_type = "feelsLike" mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" ].smart_low_temp_threshold = 5.5 @@ -946,7 +1038,7 @@ async def test_climate_climate_react( hass.states.get("sensor.hallway_climate_react_high_temperature_threshold").state == "30.5" ) - assert hass.states.get("sensor.hallway_climate_react_type").state == "temperature" + assert hass.states.get("sensor.hallway_climate_react_type").state == "feelslike" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1187,3 +1279,33 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( state = hass.states.get("climate.hallway") assert state.attributes["fan_mode"] == "high" assert state.attributes["swing_mode"] == "stopped" + + +async def test_climate_get_device_capabilities( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test the Sensibo climate Get device capabilitites service.""" + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DEVICE_CAPABILITIES, + {ATTR_ENTITY_ID: "climate.hallway", ATTR_HVAC_MODE: "heat"}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + with pytest.raises( + ServiceValidationError, match="The entity does not support the chosen mode" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_DEVICE_CAPABILITIES, + {ATTR_ENTITY_ID: "climate.hallway", ATTR_HVAC_MODE: "heat_cool"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 78eee6ceba0a27..b4911983fe77f5 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -2,8 +2,14 @@ from __future__ import annotations +from datetime import timedelta +from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData +import pytest + from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigEntryState @@ -13,7 +19,7 @@ from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -103,3 +109,73 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, load_int.entry_id) assert response["success"] + + +@pytest.mark.parametrize( + ("entity_id", "device_ids"), + [ + # Device is ABC999111 + ("climate.hallway", ["ABC999111"]), + ("binary_sensor.hallway_filter_clean_required", ["ABC999111"]), + ("number.hallway_temperature_calibration", ["ABC999111"]), + ("sensor.hallway_filter_last_reset", ["ABC999111"]), + ("update.hallway_firmware", ["ABC999111"]), + # Device is AABBCC belonging to device ABC999111 + ("binary_sensor.hallway_motion_sensor_motion", ["ABC999111", "AABBCC"]), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + entity_id: str, + device_ids: list[str], +) -> None: + """Test for automatic device addition and removal.""" + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Remove one of the devices + new_device_list = [ + device for device in get_data[2]["result"] if device["id"] != device_ids[0] + ] + mock_client.async_get_devices.return_value = { + "status": "success", + "result": new_device_list, + } + new_data = {k: v for k, v in get_data[0].parsed.items() if k != device_ids[0]} + new_raw = mock_client.async_get_devices.return_value["result"] + mock_client.async_get_devices_data.return_value = SensiboData(new_raw, new_data) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert not state + assert not entity_registry.async_get(entity_id) + for device_id in device_ids: + assert not device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the device back + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_devices_data.return_value = get_data[0] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 5e1c3f68e41987..75dbdc88840f88 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -14,13 +14,16 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir -from tests.common import async_fire_time_changed, snapshot_platform +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -40,15 +43,15 @@ async def test_select( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" - ].horizontal_swing_mode = "fixedleft" + "AAZZAAZZ" + ].light_mode = "dim" freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "fixedleft" + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" async def test_select_set_option( @@ -60,7 +63,7 @@ async def test_select_set_option( """Test the Sensibo select service.""" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", @@ -73,8 +76,8 @@ async def test_select_set_option( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_set_ac_state_property.return_value = { "result": {"status": "failed"} @@ -86,21 +89,19 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", "mode", - "targetTemperature", - "horizontalSwing", "light", ] @@ -118,12 +119,12 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_set_ac_state_property.return_value = { "result": {"status": "Success"} @@ -132,9 +133,108 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" + + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.kitchen_light") + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SELECT]], +) +async def test_deprecated_horizontal_swing_select( + hass: HomeAssistant, + load_platforms: list[Platform], + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated horizontal swing select entity.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="firstnamelastname", + version=2, + ) + + config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SELECT_DOMAIN, + DOMAIN, + "ABC999111-horizontalSwing", + config_entry=config_entry, + disabled_by=None, + has_entity_name=True, + suggested_object_id="hallway_horizontal_swing", + ) + + with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "fixedleft" + assert state.state == "stopped" + + # No issue created without automation or script + assert issue_registry.issues == {} + + with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), + patch( + # Patch check for automation, that one exist + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], + ), + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) + + # Issue is created when entity is enabled and automation/script exist + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert issue + assert issue.translation_key == "deprecated_entity_horizontalswing" + assert hass.states.get("select.hallway_horizontal_swing") + assert entity_registry.async_is_registered("select.hallway_horizontal_swing") + + # Disabling the entity should remove the entity and remove the issue + # once the integration is reloaded + entity_registry.async_update_entity( + state.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + + with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), + patch( + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], + ), + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) + + # Disabling the entity and reloading has removed the entity and issue + assert not hass.states.get("select.hallway_horizontal_swing") + assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert not issue diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 0ea46a412737fd..604cd91770ec11 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -570,7 +570,7 @@ async def test_unit_translation_key_without_platform_raises( match="cannot have a translation key for unit of measurement before " "being added to the entity platform", ): - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) @@ -580,7 +580,7 @@ async def test_unit_translation_key_without_platform_raises( await hass.async_block_till_done() # Should not raise after being added to the platform - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement assert unit == "Tests" @@ -2144,7 +2144,7 @@ async def test_non_numeric_validation_raise( (13, "13"), (17.50, "17.5"), ("1e-05", "1e-05"), - (Decimal(18.50), "18.5"), + (Decimal("18.50"), "18.50"), ("19.70", "19.70"), (None, STATE_UNKNOWN), ], diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 636fb9871c9759..d011926848dcfd 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -121,15 +121,6 @@ def disable_mariadb_issue() -> None: yield -@pytest.fixture(autouse=True) -def disable_sqlite_issue() -> None: - """Disable creating issue about outdated SQLite version.""" - with patch( - "homeassistant.components.recorder.util._async_create_issue_deprecated_version" - ): - yield - - async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7de45eeee9865b..7a20560e25f1ad 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -148,6 +148,13 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state +def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: + """Return entity attribute.""" + entity = hass.states.get(entity_id) + assert entity + return entity.attributes[attribute] + + def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b2550c2b9d4355..7bcc1c04c6a158 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -202,6 +202,64 @@ def mock_white_light_set_state( "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, } + +MOCK_BLU_TRV_REMOTE_CONFIG = { + "components": [ + { + "key": "blutrv:200", + "status": { + "id": 200, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 200, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "key": None, + "trv": "bthomedevice:200", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, + ], + "blutrv:200": { + "id": 0, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "local_name": "SBTR-001AEU", + }, +} + + +MOCK_BLU_TRV_REMOTE_STATUS = { + "blutrv:200": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "errors": [], + }, +} + + MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, @@ -373,6 +431,24 @@ def _mock_rpc_device(version: str | None = None): return device +def _mock_blu_rtv_device(version: str | None = None): + """Mock rpc (Gen2, Websocket) device.""" + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG | MOCK_BLU_TRV_REMOTE_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "1.0.0", + hostname="test-host", + status=MOCK_STATUS_RPC | MOCK_BLU_TRV_REMOTE_STATUS, + firmware_version="some fw string", + initialized=True, + connected=True, + ) + type(device).name = PropertyMock(return_value="Test name") + return device + + @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -420,3 +496,24 @@ def initialized(): @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=True) +async def mock_blu_trv(): + """Mock BLU TRV.""" + + with ( + patch("aioshelly.rpc_device.RpcDevice.create") as blu_trv_device_mock, + patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), + ): + + def update(): + blu_trv_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + device = _mock_blu_rtv_device() + blu_trv_device_mock.return_value = device + blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield blu_trv_device_mock.return_value diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index aeeeca30edd284..352bdcb0a7d69d 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock -from aioshelly.const import MODEL_VALVE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + MODEL_BLU_GATEWAY_GEN3, + MODEL_VALVE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -37,7 +42,13 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + get_entity_attribute, + init_integration, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -294,13 +305,13 @@ async def test_block_restored_climate( assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 -async def test_block_restored_climate_us_customery( +async def test_block_restored_climate_us_customary( hass: HomeAssistant, mock_block_device: Mock, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block restored climate with US CUSTOMATY unit system.""" + """Test block restored climate with US CUSTOMARY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") @@ -759,3 +770,82 @@ async def test_wall_display_thermostat_mode_external_actuator( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_blu_trv_climate_set_temperature( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV set target temperature.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_blu_trv.mock_update() + + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": 28.0}, + }, + ) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + + +async def test_blu_trv_climate_disabled( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV disabled.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False + ) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + + +async def test_blu_trv_climate_hvac_action( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV is heating.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + + monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index d99457061826d9..b5f87a874c3774 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -15,7 +15,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, @@ -25,6 +24,10 @@ from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -33,22 +36,22 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ) -DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WITH_MAC = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, type="mock_type", ) @@ -1459,13 +1462,13 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ), ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b5516485501424..270e21636354a8 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -545,3 +545,22 @@ async def test_sleeping_block_device_wrong_sleep_period( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + with patch("homeassistant.components.shelly.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) diff --git a/tests/components/slack/__init__.py b/tests/components/slack/__init__.py index acb52a11a6c48e..507e96294ffd83 100644 --- a/tests/components/slack/__init__.py +++ b/tests/components/slack/__init__.py @@ -12,7 +12,7 @@ from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -AUTH_URL = "https://www.slack.com/api/auth.test" +AUTH_URL = "https://slack.com/api/auth.test" TOKEN = "abc123" TEAM_NAME = "Test Team" diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 565b5ec2149fba..6d0953da5e9759 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -81,7 +81,7 @@ async def test_flow_user_cannot_connect( async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: """Test user initialized flow with unreachable server.""" with patch( - "homeassistant.components.slack.config_flow.WebClient.auth_test" + "homeassistant.components.slack.config_flow.AsyncWebClient.auth_test" ) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py index 9f2923988ca6e3..b8b69d99fd802f 100644 --- a/tests/components/slide_local/test_config_flow.py +++ b/tests/components/slide_local/test_config_flow.py @@ -12,11 +12,11 @@ import pytest from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_platform from .const import HOST, SLIDE_INFO_DATA diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c7de3851b5fd38 --- /dev/null +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + 'group': 'user', + 'host': '1.1.1.1', + 'password': '**REDACTED**', + 'ssl': True, + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'sma', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'import', + 'title': 'SMA Device Name', + 'unique_id': '123456789', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py new file mode 100644 index 00000000000000..6c1fe0dc5cbcec --- /dev/null +++ b/tests/components/sma/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test the SMA diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index c06ab551ef6a1f..205c700a402ba8 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import setup -from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( CONF_SERIALNUMBER, DOMAIN, @@ -20,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,7 +63,7 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -95,7 +95,7 @@ async def test_show_zeroconf_connection_error_form_next_generation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -179,7 +179,7 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -305,7 +305,7 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -355,7 +355,7 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -377,7 +377,7 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -504,7 +504,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -589,7 +589,7 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d614..05ddc3a71de6aa 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb8c8..83372b5822865e 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready( diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 021ee9cc810df7..7e6768e4d7d112 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -36,9 +36,9 @@ async def test_mapping_integrity() -> None: for sensor_map in maps: assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute if sensor_map.device_class: - assert ( - sensor_map.device_class in DEVICE_CLASSES - ), sensor_map.device_class + assert sensor_map.device_class in DEVICE_CLASSES, ( + sensor_map.device_class + ) if sensor_map.state_class: assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index a0bbf8546992e4..0e65d288737dad 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -2,7 +2,6 @@ ENTITY_ID = "weather.smhi_test" TEST_CONFIG = { - "name": "test", "location": { "longitude": "17.84197", "latitude": "59.32624", @@ -11,5 +10,5 @@ TEST_CONFIG_MIGRATE = { "name": "test", "longitude": "17.84197", - "latitude": "17.84197", + "latitude": "59.32624", } diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 4195d1e5d52aa9..362adebe416228 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -57,7 +57,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 0.0, "longitude": 0.0, }, - "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -93,7 +92,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 1.0, "longitude": 1.0, }, - "name": "Weather", } @@ -150,7 +148,6 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: "latitude": 2.0, "longitude": 2.0, }, - "name": "Weather", } @@ -201,8 +198,8 @@ async def test_reconfigure_flow( domain=DOMAIN, title="Home", unique_id="57.2898-13.6304", - data={"location": {"latitude": 57.2898, "longitude": 13.6304}, "name": "Home"}, - version=2, + data={"location": {"latitude": 57.2898, "longitude": 13.6304}}, + version=3, ) entry.add_to_hass(hass) @@ -269,7 +266,6 @@ async def test_reconfigure_flow( "latitude": 58.2898, "longitude": 14.6304, }, - "name": "Home", } entity = entity_registry.async_get(entity.entity_id) assert entity diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index cfb386c8f6fcac..d00742d4900b6e 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,10 +1,9 @@ """Test SMHI component setup process.""" -from unittest.mock import patch - from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +21,7 @@ async def test_setup_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -40,7 +39,7 @@ async def test_remove_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -77,7 +76,7 @@ async def test_migrate_entry( original_name="Weather", platform="smhi", supported_features=0, - unique_id="17.84197, 17.84197", + unique_id="59.32624, 17.84197", ) await hass.config_entries.async_setup(entry.entry_id) @@ -86,30 +85,27 @@ async def test_migrate_entry( state = hass.states.get(entity.entity_id) assert state - assert entry.version == 2 - assert entry.unique_id == "17.84197-17.84197" + assert entry.version == 3 + assert entry.unique_id == "59.32624-17.84197" + assert entry.data == TEST_CONFIG entity_get = entity_registry.async_get(entity.entity_id) - assert entity_get.unique_id == "17.84197, 17.84197" + assert entity_get.unique_id == "59.32624, 17.84197" -async def test_migrate_entry_failed( +async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: - """Test migrate entry data that fails.""" + """Test migrate entry not possible from future version.""" uri = APIURL_TEMPLATE.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE, version=4) entry.add_to_hass(hass) - assert entry.version == 1 + assert entry.version == 4 - with patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=False, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.version == 1 + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 1870d7b498a7b3..cc6902710bdb4a 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -49,7 +49,7 @@ async def test_setup_hass( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -80,7 +80,7 @@ async def test_clear_night( ) aioclient_mock.get(uri, text=api_response_night) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -105,7 +105,7 @@ async def test_clear_night( async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with patch( @@ -193,7 +193,7 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with ( @@ -232,7 +232,7 @@ async def test_refresh_weather_forecast_retry( hass: HomeAssistant, error: Exception ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) now = dt_util.utcnow() @@ -357,7 +357,7 @@ async def test_custom_speed_unit( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -394,7 +394,7 @@ async def test_forecast_services( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -458,7 +458,7 @@ async def test_forecast_services_lack_of_data( ) aioclient_mock.get(uri, text=api_response_lack_data) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -503,7 +503,7 @@ async def test_forecast_service( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 2fd39f757043e3..146f8e268a47a6 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -6,18 +6,18 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.smlight.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", @@ -27,7 +27,7 @@ type="mock_type", ) -DISCOVERY_INFO_LEGACY = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 9084d988ec96a2..b7007f27fa9819 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -5,7 +5,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.somfy_mylink.const import ( CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, @@ -14,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT 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 @@ -263,7 +263,7 @@ async def test_form_user_already_configured_from_dhcp(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -287,7 +287,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -302,7 +302,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py index 5215e9b3c0e27f..0ae2ab596db5bd 100644 --- a/tests/components/songpal/test_config_flow.py +++ b/tests/components/songpal/test_config_flow.py @@ -4,7 +4,6 @@ import dataclasses from unittest.mock import patch -from homeassistant.components import ssdp from homeassistant.components.songpal.const import CONF_ENDPOINT, DOMAIN from homeassistant.config_entries import ( SOURCE_IMPORT, @@ -15,6 +14,11 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import ( CONF_DATA, @@ -30,13 +34,13 @@ UDN = "uuid:1234" -SSDP_DATA = ssdp.SsdpServiceInfo( +SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: UDN, - ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + ATTR_UPNP_UDN: UDN, + ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_BaseURL": ENDPOINT, "X_ScalarWebAPI_ServiceList": { diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 04b35e2c021497..0f56794b9f2c75 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -18,11 +18,13 @@ ) from soco.events_base import Event as SonosEvent -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @@ -108,7 +110,7 @@ def increment_variable(self, var_name): @pytest.fixture def zeroconf_payload(): """Return a default zeroconf payload.""" - return zeroconf.ZeroconfServiceInfo( + return ZeroconfServiceInfo( ip_address=ip_address("192.168.4.2"), ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", @@ -335,17 +337,17 @@ def discover_fixture(soco): def do_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ssdp.SsdpChange.ALIVE, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 141013dec20c7e..70605092da1bc1 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -6,17 +6,18 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component async def test_user_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we get the user initiated form.""" @@ -84,7 +85,7 @@ async def test_user_form_already_created(hass: HomeAssistant) -> None: async def test_zeroconf_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we pass Zeroconf discoveries to the manager.""" @@ -128,12 +129,12 @@ async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ) @@ -173,7 +174,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.107"), ip_addresses=[ip_address("192.168.1.107")], port=1443, @@ -221,7 +222,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: async def test_zeroconf_form_not_sonos( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we abort on non-sonos devices.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 36a6571f3b0890..3fc8da6a9521a8 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -8,7 +8,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components import sonos, zeroconf +from homeassistant.components import sonos from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( DATA_SONOS_DISCOVERY_MANAGER, @@ -19,6 +19,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -28,7 +29,7 @@ async def test_creating_entry_sets_up_media_player( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test setting up Sonos loads the media player.""" diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 264049ab5fc84b..fe524da5603b9b 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -8,11 +8,11 @@ from requests_mock import ANY, Mocker from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import DEVICE_1_ID, DEVICE_1_IP, DEVICE_1_NAME diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cb942a635684e4..24c0e1d41d957f 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -7,18 +7,18 @@ import pytest from spotifyaio import SpotifyConnectionError -from homeassistant.components import zeroconf from homeassistant.components.spotify.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( +BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index f2c9636c47096c..c5efe66152f18b 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,11 +6,11 @@ from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME 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 @@ -143,27 +143,67 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "edit"} - ) async def patch_async_query(self, *args): self.http_status = HTTPStatus.UNAUTHORIZED return False - with patch("pysqueezebox.Server.async_query", new=patch_async_query): + with ( + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + with patch( + "homeassistant.components.squeezebox.config_flow.Server.async_query", + new=patch_async_query, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: HOST, CONF_PORT: PORT, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, }, ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_form_validate_exception(hass: HomeAssistant) -> None: @@ -293,7 +333,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -315,7 +355,7 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -334,7 +374,7 @@ async def test_dhcp_discovery_existing_player(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7dc0f0095d4e97..839509e756b158 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from ipaddress import IPv4Address +from typing import Any from unittest.mock import ANY, AsyncMock, patch from async_upnp_client.server import UpnpServer @@ -19,6 +20,24 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT, + ATTR_ST, + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + ATTR_UPNP_UPC, + SsdpServiceInfo, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -26,6 +45,7 @@ MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -74,7 +94,7 @@ async def test_ssdp_flow_dispatched_on_st( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -83,7 +103,7 @@ async def test_ssdp_flow_dispatched_on_st( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -118,7 +138,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -127,7 +147,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -227,8 +247,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Paulus", } ] }, @@ -270,8 +290,8 @@ async def test_scan_not_all_present( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] }, @@ -455,8 +475,8 @@ async def test_discovery_from_advertisement_sets_ssdp_st( assert discovery_info.ssdp_headers["nts"] == "ssdp:alive" assert discovery_info.ssdp_headers["_timestamp"] == ANY assert discovery_info.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:mock-udn", } @@ -555,7 +575,7 @@ async def test_scan_with_registered_callback( assert async_match_any_callback.call_count == 1 assert async_not_matching_integration_callback.call_count == 0 assert async_integration_callback.call_args[0][1] == ssdp.SsdpChange.ALIVE - mock_call_data: ssdp.SsdpServiceInfo = async_integration_callback.call_args[0][0] + mock_call_data: SsdpServiceInfo = async_integration_callback.call_args[0][0] assert mock_call_data.ssdp_ext == "" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_server == "mock-server" @@ -568,8 +588,8 @@ async def test_scan_with_registered_callback( assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == set() assert mock_call_data.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert "Exception in SSDP callback" in caplog.text @@ -627,8 +647,8 @@ async def test_getting_existing_headers( assert discovery_info_by_st.ssdp_udn == ANY assert discovery_info_by_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( @@ -646,8 +666,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn.ssdp_udn == ANY assert discovery_info_by_udn.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( @@ -664,8 +684,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn_st.ssdp_udn == ANY assert discovery_info_by_udn_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert ( @@ -713,7 +733,7 @@ async def test_getting_existing_headers( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -738,7 +758,7 @@ async def test_async_detect_interfaces_setting_empty_route( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -787,7 +807,7 @@ async def _async_start(self): return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -999,7 +1019,7 @@ async def test_ssdp_rediscover( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1086,7 +1106,7 @@ async def test_ssdp_rediscover_no_match( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1094,3 +1114,105 @@ async def test_ssdp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_flow_init.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "SsdpServiceInfo", + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + SsdpServiceInfo, + ), + ( + "ATTR_ST", + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + ATTR_ST, + ), + ( + "ATTR_NT", + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + ATTR_NT, + ), + ( + "ATTR_UPNP_DEVICE_TYPE", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + ATTR_UPNP_DEVICE_TYPE, + ), + ( + "ATTR_UPNP_FRIENDLY_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + ATTR_UPNP_FRIENDLY_NAME, + ), + ( + "ATTR_UPNP_MANUFACTURER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + ATTR_UPNP_MANUFACTURER, + ), + ( + "ATTR_UPNP_MANUFACTURER_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + ATTR_UPNP_MANUFACTURER_URL, + ), + ( + "ATTR_UPNP_MODEL_DESCRIPTION", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + ATTR_UPNP_MODEL_DESCRIPTION, + ), + ( + "ATTR_UPNP_MODEL_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + ATTR_UPNP_MODEL_NAME, + ), + ( + "ATTR_UPNP_MODEL_NUMBER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + ATTR_UPNP_MODEL_NUMBER, + ), + ( + "ATTR_UPNP_MODEL_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + ATTR_UPNP_MODEL_URL, + ), + ( + "ATTR_UPNP_SERIAL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + ATTR_UPNP_SERIAL, + ), + ( + "ATTR_UPNP_SERVICE_LIST", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + ATTR_UPNP_SERVICE_LIST, + ), + ( + "ATTR_UPNP_UDN", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + ATTR_UPNP_UDN, + ), + ( + "ATTR_UPNP_UPC", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + ATTR_UPNP_UPC, + ), + ( + "ATTR_UPNP_PRESENTATION_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + ATTR_UPNP_PRESENTATION_URL, + ), + ], +) +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, + ssdp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/steamist/test_config_flow.py b/tests/components/steamist/test_config_flow.py index 40578113bb39e4..5e963f77a2b9f9 100644 --- a/tests/components/steamist/test_config_flow.py +++ b/tests/components/steamist/test_config_flow.py @@ -5,11 +5,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.steamist.const import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_DATA, @@ -30,7 +30,7 @@ MODULE = "homeassistant.components.steamist" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), @@ -238,7 +238,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=DEVICE_IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 0df9b55d1a99f7..ddefb6be772260 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -100,6 +100,7 @@ async def test_entity_entry_migration( entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entry data.""" + mock_config_entry.add_to_hass(hass) entity = entity_registry.async_get_or_create( suggested_object_id="advice", disabled_by=None, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 9ce297c3fb6637..cd48fd94c241fc 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -119,13 +119,16 @@ def make_playlist( response.extend( [ f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * part_target_duration:.3f},PRECISE=YES", ] ) else: response.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*segment_duration:.3f},PRECISE=YES", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * segment_duration:.3f},PRECISE=YES", ) if segments: response.extend(segments) diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 5577076830b560..443103fdf92707 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -99,18 +99,17 @@ def make_segment_with_parts( if discontinuity: response.append("#EXT-X-DISCONTINUITY") response.extend( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + f"#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f}," + f'URI="./segment/{segment}.{i}.m4s"' + f"{',INDEPENDENT=YES' if i % independent_period == 0 else ''}" for i in range(num_parts) ) - response.extend( - [ - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXTINF:{math.ceil(SEGMENT_DURATION/TEST_PART_DURATION)*TEST_PART_DURATION:.3f},", - f"./segment/{segment}.m4s", - ] + response.append( + f"#EXT-X-PROGRAM-DATE-TIME:{FAKE_TIME.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" ) + duration = math.ceil(SEGMENT_DURATION / TEST_PART_DURATION) * TEST_PART_DURATION + response.append(f"#EXTINF:{duration:.3f},") + response.append(f"./segment/{segment}.m4s") return "\n".join(response) @@ -166,7 +165,7 @@ async def test_ll_hls_stream( # Fetch playlist playlist_url = "/" + master_playlist.splitlines()[-1] playlist_response = await hls_client.get( - playlist_url + f"?_HLS_msn={num_playlist_segments-1}" + playlist_url + f"?_HLS_msn={num_playlist_segments - 1}" ) assert playlist_response.status == HTTPStatus.OK @@ -465,7 +464,8 @@ async def _handler_bad_request(request): ).status == HTTPStatus.BAD_REQUEST assert ( await hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + "/playlist.m3u8?_HLS_msn=1&_HLS_part=" + f"{num_completed_parts - 1 + hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" ) ).status == HTTPStatus.BAD_REQUEST stream_worker_sync.resume() @@ -515,13 +515,13 @@ async def test_ll_hls_playlist_rollover_part( *( [ hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) - 1}" ), hls_client.get( f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) + 1}" ), hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), ] diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 43431ae04c0ef8..d5728faf36903a 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -116,7 +116,7 @@ async def test_setup_entry_fails_when_refreshing( mock_get_status.side_effect = CannotConnect entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index defe970c6749c3..57454e38062a8c 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -91,8 +91,8 @@ DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] -DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] -DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 9bfe11fe202f22..c20149de074c97 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -20,7 +20,20 @@ from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -137,3 +150,192 @@ async def test_switch_control_fail( mock_control_device.assert_called_once_with(Command.ON) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index b79e63e1ce7aad..727b95563cc7b7 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -6,12 +6,19 @@ from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,16 +145,16 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.2:5200/Printer.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", }, ), ) diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index e5494b7179f5d0..3ef47292a9b245 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -13,7 +13,6 @@ ) from syrupy import SnapshotAssertion -from homeassistant.components import ssdp, zeroconf from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_SNAPSHOT_QUALITY, @@ -34,6 +33,12 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .consts import ( DEVICE_TOKEN, @@ -418,13 +423,13 @@ async def test_form_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` }, ), ) @@ -465,13 +470,13 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -508,13 +513,13 @@ async def test_skip_reconfig_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{new_host}:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -541,13 +546,13 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -606,7 +611,7 @@ async def test_discovered_via_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, @@ -645,7 +650,7 @@ async def test_discovered_via_zeroconf_missing_mac( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 0606ce8e258869..89bd1b652ba80a 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -15,9 +15,9 @@ from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM from systembridgemodels.modules import Module, ModulesData -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -44,7 +44,7 @@ CONF_PORT: "9170", } -FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, @@ -62,7 +62,7 @@ }, ) -FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF_BAD = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py index f5cc46da0960d0..60a70da27a2aeb 100644 --- a/tests/components/systemmonitor/test_config_flow.py +++ b/tests/components/systemmonitor/test_config_flow.py @@ -48,17 +48,9 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" async def test_add_and_remove_processes( diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 63b17dad13ea43..19acb0aecbd79d 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -9,7 +9,6 @@ import requests from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tado.config_flow import NoHomes from homeassistant.components.tado.const import ( CONF_FALLBACK, @@ -19,16 +18,20 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry -def _get_mock_tado_api(getMe=None) -> MagicMock: +def _get_mock_tado_api(get_me=None) -> MagicMock: mock_tado = MagicMock() - if isinstance(getMe, Exception): - type(mock_tado).getMe = MagicMock(side_effect=getMe) + if isinstance(get_me, Exception): + type(mock_tado).get_me = MagicMock(side_effect=get_me) else: - type(mock_tado).getMe = MagicMock(return_value=getMe) + type(mock_tado).get_me = MagicMock(return_value=get_me) return mock_tado @@ -61,7 +64,7 @@ async def test_form_exceptions( assert result["errors"] == {"base": error} # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -131,7 +134,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -166,7 +169,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -189,7 +194,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -210,7 +217,7 @@ async def test_no_homes(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_tado_api = _get_mock_tado_api(getMe={"homes": []}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -231,13 +238,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -258,13 +265,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -314,7 +321,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( "homeassistant.components.tado.config_flow.Tado", diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index ca6fbacf0fc9bb..2e8a8e7a727b74 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -11,13 +11,13 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -208,7 +208,7 @@ async def test_zeroconf_flow_abort_incompatible_properties( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -243,7 +243,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -303,7 +303,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 4f4daee1301ce0..6a2b7699840abe 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1514,7 +1514,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1524,7 +1524,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Channel{idx+num_switches+1} {(idx+1)*10}", + f"NoDelay;Channel{idx + num_switches + 1} {(idx + 1) * 10}", 0, False, ) @@ -1595,7 +1595,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1605,7 +1605,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Dimmer{idx+1} {(idx+1)*10}", + f"NoDelay;Dimmer{idx + 1} {(idx + 1) * 10}", 0, False, ) diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index cc2dcf4a04af78..f08dd6970fe334 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -45,53 +45,6 @@ 'state': 'off', }) # --- -# name: test_sensors[binary_sensor.technove_station_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.technove_station_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging', - 'platform': 'technove', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'AA:AA:AA:AA:AA:BB_is_session_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[binary_sensor.technove_station_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'TechnoVE Station Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.technove_station_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py index 0a90093779e796..93d4805cecb2c2 100644 --- a/tests/components/technove/test_binary_sensor.py +++ b/tests/components/technove/test_binary_sensor.py @@ -45,7 +45,6 @@ async def test_sensors( "entity_id", [ "binary_sensor.technove_station_static_ip", - "binary_sensor.technove_station_charging", ], ) @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index 81e0b32b55b81c..99a8e231f73f3d 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -6,12 +6,12 @@ import pytest from technove import TechnoVEConnectionError -from homeassistant.components import zeroconf from homeassistant.components.technove.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -153,7 +153,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -184,7 +184,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -225,7 +225,7 @@ async def test_zeroconf_without_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -250,7 +250,7 @@ async def test_zeroconf_with_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 93137c3815eb05..f15db7eba2bb45 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -105,6 +105,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: patch.object(BotMock, "get_me", return_value=test_user), patch.object(BotMock, "bot", test_user), patch.object(BotMock, "send_message", return_value=message), + patch.object(BotMock, "send_photo", return_value=message), + patch.object(BotMock, "send_sticker", return_value=message), + patch.object(BotMock, "send_video", return_value=message), + patch.object(BotMock, "send_document", return_value=message), + patch.object(BotMock, "send_voice", return_value=message), + patch.object(BotMock, "send_animation", return_value=message), + patch.object(BotMock, "send_location", return_value=message), + patch.object(BotMock, "send_poll", return_value=message), patch("telegram.ext.Updater._bootstrap"), ): yield diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index bdf6ba72fccf79..be6b5b313256b9 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,17 +1,33 @@ """Tests for the telegram_bot component.""" +import base64 +import io from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import Update from telegram.error import NetworkError, RetryAfter, TelegramError, TimedOut from homeassistant.components.telegram_bot import ( + ATTR_FILE, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MESSAGE, ATTR_MESSAGE_THREAD_ID, + ATTR_OPTIONS, + ATTR_QUESTION, + ATTR_STICKER_ID, DOMAIN, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_LOCATION, SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_POLL, + SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.const import EVENT_HOMEASSISTANT_START @@ -32,23 +48,125 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True -async def test_send_message(hass: HomeAssistant, webhook_platform) -> None: - """Test the send_message service.""" +@pytest.mark.parametrize( + ("service", "input"), + [ + ( + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + ), + ( + SERVICE_SEND_STICKER, + { + ATTR_STICKER_ID: "1", + ATTR_MESSAGE_THREAD_ID: "123", + }, + ), + ( + SERVICE_SEND_POLL, + { + ATTR_QUESTION: "Question", + ATTR_OPTIONS: ["Yes", "No"], + }, + ), + ( + SERVICE_SEND_LOCATION, + { + ATTR_MESSAGE: "test_message", + ATTR_MESSAGE_THREAD_ID: "123", + ATTR_LONGITUDE: "1.123", + ATTR_LATITUDE: "1.123", + }, + ), + ], +) +async def test_send_message( + hass: HomeAssistant, webhook_platform, service: str, input: dict[str] +) -> None: + """Test the send_message service. Tests any service that does not require files to be sent.""" context = Context() events = async_capture_events(hass, "telegram_sent") - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, - SERVICE_SEND_MESSAGE, - {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + service, + input, blocking=True, context=context, + return_response=True, ) await hass.async_block_till_done() assert len(events) == 1 assert events[0].context == context + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + +@patch( + "builtins.open", + mock_open( + read_data=base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + ) + ), + create=True, +) +def _read_file_as_bytesio_mock(file_path): + """Convert file to BytesIO for testing.""" + _file = None + + with open(file_path, encoding="utf8") as file_handler: + _file = io.BytesIO(file_handler.read()) + + _file.name = "dummy" + _file.seek(0) + + return _file + + +@pytest.mark.parametrize( + "service", + [ + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + ], +) +async def test_send_file(hass: HomeAssistant, webhook_platform, service: str) -> None: + """Test the send_file service (photo, animation, video, document...).""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + hass.config.allowlist_external_dirs.add("/media/") + + # Mock the file handler read with our base64 encoded dummy file + with patch( + "homeassistant.components.telegram_bot._read_file_as_bytesio", + _read_file_as_bytesio_mock, + ): + response = await hass.services.async_call( + DOMAIN, + service, + { + ATTR_FILE: "/media/dummy", + ATTR_MESSAGE_THREAD_ID: "123", + }, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> None: """Test the send_message service for threads.""" diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 2396e2a88f3296..06d2b54c93636c 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -15,6 +15,7 @@ from .const import ( COMMAND_OK, + ENERGY_HISTORY, LIVE_STATUS, PRODUCTS, SITE_INFO, @@ -177,6 +178,16 @@ def mock_request(): yield mock_request +@pytest.fixture(autouse=True) +def mock_energy_history(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.energy_history", + return_value=ENERGY_HISTORY, + ) as mock_live_status: + yield mock_live_status + + @pytest.fixture(autouse=True) def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" diff --git a/tests/components/tesla_fleet/const.py b/tests/components/tesla_fleet/const.py index 76b4ae2009299b..d584e7b93d56b1 100644 --- a/tests/components/tesla_fleet/const.py +++ b/tests/components/tesla_fleet/const.py @@ -11,6 +11,7 @@ VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} diff --git a/tests/components/tesla_fleet/fixtures/energy_history.json b/tests/components/tesla_fleet/fixtures/energy_history.json new file mode 100644 index 00000000000000..befe12cc903ce3 --- /dev/null +++ b/tests/components/tesla_fleet/fixtures/energy_history.json @@ -0,0 +1,45 @@ +{ + "response": { + "period": "day", + "time_series": [ + { + "timestamp": "2023-06-01T01:00:00-07:00", + "solar_energy_exported": 70940, + "generator_energy_exported": 0, + "grid_energy_imported": 521, + "grid_services_energy_imported": 17.53125, + "grid_services_energy_exported": 3.80859375, + "grid_energy_exported_from_solar": 43660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 19, + "battery_energy_exported": 10030, + "battery_energy_imported_from_grid": 80, + "battery_energy_imported_from_solar": 16800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 441, + "consumer_energy_imported_from_solar": 10480, + "consumer_energy_imported_from_battery": 10011, + "consumer_energy_imported_from_generator": 0 + }, + { + "timestamp": "2023-06-01T01:05:00-07:00", + "solar_energy_exported": 140940, + "generator_energy_exported": 1, + "grid_energy_imported": 1021, + "grid_services_energy_imported": 27.53125, + "grid_services_energy_exported": 6.80859375, + "grid_energy_exported_from_solar": 83660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 29, + "battery_energy_exported": 20030, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 26800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 841, + "consumer_energy_imported_from_solar": 20480, + "consumer_energy_imported_from_battery": 20011, + "consumer_energy_imported_from_generator": 0 + } + ] + } +} diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index 2c3780749ca48e..d6b646d779427a 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -1,11 +1,1106 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_charged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_charge', + 'unique_id': '123456-total_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_discharged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery discharged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_discharge', + 'unique_id': '123456-total_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_exported', + 'unique_id': '123456-battery_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_generator', + 'unique_id': '123456-battery_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_grid', + 'unique_id': '123456-battery_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_solar', + 'unique_id': '123456-battery_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- # name: test_sensors[sensor.energy_site_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '123456-battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_power-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.06', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_battery', + 'unique_id': '123456-consumer_energy_imported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_generator', + 'unique_id': '123456-consumer_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_grid', + 'unique_id': '123456-consumer_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_solar', + 'unique_id': '123456-consumer_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- +# name: test_sensors[sensor.energy_site_energy_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.energy_site_energy_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy left', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_left', + 'unique_id': '123456-energy_left', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_energy_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Energy Site Energy left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_energy_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.8964736842105', + }) +# --- +# name: test_sensors[sensor.energy_site_energy_left-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Energy Site Energy left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_energy_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.8964736842105', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_energy_exported', + 'unique_id': '123456-generator_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator power', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_power', + 'unique_id': '123456-generator_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Generator power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_power-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Generator power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_grid_energy_exported', + 'unique_id': '123456-total_grid_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -13,7 +1108,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_site_battery_power', + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -26,67 +1121,67 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery power', + 'original_name': 'Grid exported from battery', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery_power', - 'unique_id': '123456-battery_power', - 'unit_of_measurement': , + 'translation_key': 'grid_energy_exported_from_battery', + 'unique_id': '123456-grid_energy_exported_from_battery', + 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_site_battery_power-state] +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Battery power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_battery_power', + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5.06', + 'state': '0.048', }) # --- -# name: test_sensors[sensor.energy_site_battery_power-statealt] +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Battery power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_battery_power', + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5.06', + 'state': '0.048', }) # --- -# name: test_sensors[sensor.energy_site_energy_left-entry] +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.energy_site_energy_left', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -102,56 +1197,275 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy left', + 'original_name': 'Grid exported from generator', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'energy_left', - 'unique_id': '123456-energy_left', + 'translation_key': 'grid_energy_exported_from_generator', + 'unique_id': '123456-grid_energy_exported_from_generator', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_site_energy_left-state] +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy_storage', - 'friendly_name': 'Energy Site Energy left', - 'state_class': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_energy_left', + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38.8964736842105', + 'state': '0.0', }) # --- -# name: test_sensors[sensor.energy_site_energy_left-statealt] +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy_storage', - 'friendly_name': 'Energy Site Energy left', + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_solar', + 'unique_id': '123456-grid_energy_exported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_imported', + 'unique_id': '123456-grid_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '123456-grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_power-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_energy_left', + 'entity_id': 'sensor.energy_site_grid_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38.8964736842105', + 'state': '0.0', }) # --- -# name: test_sensors[sensor.energy_site_generator_power-entry] +# name: test_sensors[sensor.energy_site_grid_services_exported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -159,7 +1473,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_site_generator_power', + 'entity_id': 'sensor.energy_site_grid_services_exported', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -172,59 +1486,59 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Generator power', + 'original_name': 'Grid services exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'generator_power', - 'unique_id': '123456-generator_power', - 'unit_of_measurement': , + 'translation_key': 'grid_services_energy_exported', + 'unique_id': '123456-grid_services_energy_exported', + 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_site_generator_power-state] +# name: test_sensors[sensor.energy_site_grid_services_exported-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Generator power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_generator_power', + 'entity_id': 'sensor.energy_site_grid_services_exported', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.0106171875', }) # --- -# name: test_sensors[sensor.energy_site_generator_power-statealt] +# name: test_sensors[sensor.energy_site_grid_services_exported-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Generator power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_generator_power', + 'entity_id': 'sensor.energy_site_grid_services_exported', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.0106171875', }) # --- -# name: test_sensors[sensor.energy_site_grid_power-entry] +# name: test_sensors[sensor.energy_site_grid_services_imported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -232,7 +1546,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_site_grid_power', + 'entity_id': 'sensor.energy_site_grid_services_imported', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -245,50 +1559,50 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Grid power', + 'original_name': 'Grid services imported', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grid_power', - 'unique_id': '123456-grid_power', - 'unit_of_measurement': , + 'translation_key': 'grid_services_energy_imported', + 'unique_id': '123456-grid_services_energy_imported', + 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_site_grid_power-state] +# name: test_sensors[sensor.energy_site_grid_services_imported-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Grid power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_grid_power', + 'entity_id': 'sensor.energy_site_grid_services_imported', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.0450625', }) # --- -# name: test_sensors[sensor.energy_site_grid_power-statealt] +# name: test_sensors[sensor.energy_site_grid_services_imported-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Energy Site Grid power', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_site_grid_power', + 'entity_id': 'sensor.energy_site_grid_services_imported', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.0450625', }) # --- # name: test_sensors[sensor.energy_site_grid_services_power-entry] @@ -447,6 +1761,79 @@ 'state': 'on_grid', }) # --- +# name: test_sensors[sensor.energy_site_home_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_home_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Home usage', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_home_usage', + 'unique_id': '123456-total_home_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -590,6 +1977,152 @@ 'state': '95.5053740373966', }) # --- +# name: test_sensors[sensor.energy_site_solar_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_exported', + 'unique_id': '123456-solar_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar generated', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_solar_generation', + 'unique_id': '123456-total_solar_generation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_solar_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7e97096e4e88ec..2162226efb0bf9 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -21,6 +21,7 @@ from homeassistant.components.tesla_fleet.const import AUTHORIZE_URL from homeassistant.components.tesla_fleet.coordinator import ( + ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, ENERGY_INTERVAL_SECONDS, VEHICLE_INTERVAL, @@ -317,6 +318,21 @@ async def test_energy_site_refresh_error( assert normal_config_entry.state is state +# Test Energy History Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_history_refresh_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + side_effect: TeslaFleetError, + state: ConfigEntryState, +) -> None: + """Test coordinator refresh with an error.""" + mock_energy_history.side_effect = side_effect + await setup_platform(hass, normal_config_entry) + assert normal_config_entry.state is state + + async def test_energy_live_refresh_ratelimited( hass: HomeAssistant, normal_config_entry: MockConfigEntry, @@ -379,6 +395,39 @@ async def test_energy_info_refresh_ratelimited( assert mock_site_info.call_count == 3 +async def test_energy_history_refresh_ratelimited( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator refresh handles 429.""" + + await setup_platform(hass, normal_config_entry) + + mock_energy_history.side_effect = RateLimited( + {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not call for another 10 seconds + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index e10ae190a59338..9533b7e691ed67 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -124,9 +124,9 @@ async def _test_sensors( for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) assert state, f"Unable to get state of {entity.entity_id}" - assert ( - state.state == entity.first_value - ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + assert state.state == entity.first_value, ( + f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + ) # Simulate second data update with ( @@ -147,6 +147,6 @@ async def _test_sensors( # Verify expected vs actual values of second update for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) - assert ( - state.state == entity.second_value - ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + assert state.state == entity.second_value, ( + f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + ) diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index a0c282626585bf..fc1f4199515527 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -5,11 +5,11 @@ from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.tesla_wall_connector.const import DOMAIN from homeassistant.const import CONF_HOST 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 @@ -113,7 +113,7 @@ async def test_dhcp_can_finish( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_abc", ip="1.2.3.4", macaddress="aadc44271212", @@ -146,7 +146,7 @@ async def test_dhcp_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -170,7 +170,7 @@ async def test_dhcp_error_from_wall_connector( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 256428aa7035dc..e89bab9eff109c 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest +from teslemetry_stream.stream import recursive_match from .const import ( COMMAND_OK, @@ -48,6 +49,15 @@ def mock_vehicle_data() -> Generator[AsyncMock]: yield mock_vehicle_data +@pytest.fixture +def mock_legacy(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ) as mock_pre2021: + yield mock_pre2021 + + @pytest.fixture(autouse=True) def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" @@ -109,9 +119,53 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_listen(): +def mock_add_listener(): + """Mock Teslemetry Stream listen method.""" + with patch( + "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + ) as mock_add_listener: + mock_add_listener.listeners = [] + + def unsubscribe() -> None: + return + + def side_effect(callback, filters): + mock_add_listener.listeners.append((callback, filters)) + return unsubscribe + + def send(event) -> None: + for listener, filters in mock_add_listener.listeners: + if recursive_match(filters, event): + listener(event) + + mock_add_listener.send = send + mock_add_listener.side_effect = side_effect + yield mock_add_listener + + +@pytest.fixture(autouse=True) +def mock_stream_get_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.get_config", + ) as mock_stream_get_config: + yield mock_stream_get_config + + +@pytest.fixture(autouse=True) +def mock_stream_update_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.update_config", + ) as mock_stream_update_config: + yield mock_stream_update_config + + +@pytest.fixture(autouse=True) +def mock_stream_connected(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.listen", - ) as mock_listen: - yield mock_listen + "homeassistant.components.teslemetry.TeslemetryStream.connected", + return_value=True, + ) as mock_stream_connected: + yield mock_stream_connected diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 46efed2153d112..40d55dab71fd67 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -18,6 +18,7 @@ LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/config.json b/tests/components/teslemetry/fixtures/config.json new file mode 100644 index 00000000000000..0a6d2b11ab09ae --- /dev/null +++ b/tests/components/teslemetry/fixtures/config.json @@ -0,0 +1,10 @@ +{ + "exp": 1749261108, + "hostname": "na.teslemetry.com", + "port": 4431, + "prefer_typed": true, + "pending": false, + "fields": { + "ChargeAmps": { "interval_seconds": 60 } + } +} diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json new file mode 100644 index 00000000000000..60282afc9343cc --- /dev/null +++ b/tests/components/teslemetry/fixtures/metadata.json @@ -0,0 +1,22 @@ +{ + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "energy_device_data", + "energy_cmds" + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "access": true, + "polling": true, + "proxy": true, + "firmware": "2024.44.25" + } + } +} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index fcfa0707b2c8ea..0cd238c4e521c4 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 5ef5ea92a74d4e..25b3878f4ddc65 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -190,7 +190,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 953308401092f4..e90cc9ced5578b 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -183,7 +183,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_battery_heater-entry] +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -194,8 +194,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_battery_heater', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -205,32 +205,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Battery heater', + 'original_name': 'Automatic blind spot camera', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_battery_heater_on', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_heater_on', + 'translation_key': 'automatic_blind_spot_camera', + 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_battery_heater-state] +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Battery heater', + 'friendly_name': 'Test Automatic blind spot camera', }), 'context': , - 'entity_id': 'binary_sensor.test_battery_heater', + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -241,8 +240,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -252,32 +251,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', + 'original_name': 'Automatic emergency braking off', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', - 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection_actively_cooling', + 'translation_key': 'automatic_emergency_braking_off', + 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Automatic emergency braking off', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_charge_cable-entry] +# name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -289,7 +287,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_charge_cable', + 'entity_id': 'binary_sensor.test_battery_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -299,32 +297,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Charge cable', + 'original_name': 'Battery heater', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_heater_on', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_charge_cable-state] +# name: test_binary_sensor[binary_sensor.test_battery_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Charge cable', + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', }), 'context': , - 'entity_id': 'binary_sensor.test_charge_cable', + 'entity_id': 'binary_sensor.test_battery_heater', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -336,7 +334,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -348,29 +346,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charger has multiple phases', + 'original_name': 'Blind spot collision warning chime', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_charger_phases', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_phases', + 'translation_key': 'blind_spot_collision_warning_chime', + 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-state] +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charger has multiple phases', + 'friendly_name': 'Test Blind spot collision warning chime', }), 'context': , - 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_dashcam-entry] +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,8 +379,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_dashcam', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_bms_full_charge', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -392,32 +390,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dashcam', + 'original_name': 'BMS full charge', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_dashcam_state', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dashcam_state', + 'translation_key': 'bms_full_charge_complete', + 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_dashcam-state] +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'Test Dashcam', + 'friendly_name': 'Test BMS full charge', }), 'context': , - 'entity_id': 'binary_sensor.test_dashcam', + 'entity_id': 'binary_sensor.test_bms_full_charge', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] +# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -428,8 +425,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_driver_door', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_brake_pedal', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -439,32 +436,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Front driver door', + 'original_name': 'Brake pedal', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_df', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_df', + 'translation_key': 'brake_pedal', + 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_front_driver_door-state] +# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Test Front driver door', + 'friendly_name': 'Test Brake pedal', }), 'context': , - 'entity_id': 'binary_sensor.test_front_driver_door', + 'entity_id': 'binary_sensor.test_brake_pedal', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -476,7 +472,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_front_driver_window', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -486,32 +482,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Front driver window', + 'original_name': 'Cabin overheat protection active', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_fd_window', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fd_window', + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection_actively_cooling', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_front_driver_window-state] +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front driver window', + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_front_driver_window', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] +# name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -523,7 +519,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_front_passenger_door', + 'entity_id': 'binary_sensor.test_charge_cable', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -533,32 +529,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Front passenger door', + 'original_name': 'Charge cable', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_pf', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pf', + 'translation_key': 'charge_state_conn_charge_cable', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_front_passenger_door-state] +# name: test_binary_sensor[binary_sensor.test_charge_cable-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Test Front passenger door', + 'device_class': 'connectivity', + 'friendly_name': 'Test Charge cable', }), 'context': , - 'entity_id': 'binary_sensor.test_front_passenger_door', + 'entity_id': 'binary_sensor.test_charge_cable', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -569,8 +565,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_passenger_window', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -580,32 +576,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Front passenger window', + 'original_name': 'Charge port cold weather mode', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_fp_window', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fp_window', + 'translation_key': 'charge_port_cold_weather_mode', + 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_front_passenger_window-state] +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front passenger window', + 'friendly_name': 'Test Charge port cold weather mode', }), 'context': , - 'entity_id': 'binary_sensor.test_front_passenger_window', + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_preconditioning-entry] +# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -616,8 +611,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_preconditioning', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -629,29 +624,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Preconditioning', + 'original_name': 'Charger has multiple phases', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'climate_state_is_preconditioning', - 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_preconditioning', + 'translation_key': 'charge_state_charger_phases', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_phases', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_preconditioning-state] +# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Preconditioning', + 'friendly_name': 'Test Charger has multiple phases', }), 'context': , - 'entity_id': 'binary_sensor.test_preconditioning', + 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-entry] +# name: test_binary_sensor[binary_sensor.test_dashcam-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -663,7 +658,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'entity_id': 'binary_sensor.test_dashcam', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -673,31 +668,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Preconditioning enabled', + 'original_name': 'Dashcam', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_preconditioning_enabled', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_preconditioning_enabled', + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dashcam_state', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-state] +# name: test_binary_sensor[binary_sensor.test_dashcam-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Preconditioning enabled', + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', }), 'context': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'entity_id': 'binary_sensor.test_dashcam', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -708,8 +704,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_driver_door', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -719,32 +715,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rear driver door', + 'original_name': 'DC to DC converter', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_dr', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dr', + 'translation_key': 'dc_dc_enable', + 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_driver_door-state] +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Test Rear driver door', + 'friendly_name': 'Test DC to DC converter', }), 'context': , - 'entity_id': 'binary_sensor.test_rear_driver_door', + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] +# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -755,8 +750,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_driver_window', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_drive_rail', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -766,32 +761,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rear driver window', + 'original_name': 'Drive rail', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_rd_window', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rd_window', + 'translation_key': 'drive_rail', + 'unique_id': 'LRW3F7EK4NC700000-drive_rail', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_driver_window-state] +# name: test_binary_sensor[binary_sensor.test_drive_rail-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear driver window', + 'friendly_name': 'Test Drive rail', }), 'context': , - 'entity_id': 'binary_sensor.test_rear_driver_window', + 'entity_id': 'binary_sensor.test_drive_rail', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -802,8 +796,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_belt', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -813,32 +807,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rear passenger door', + 'original_name': 'Driver seat belt', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_pr', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pr', + 'translation_key': 'driver_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-state] +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Test Rear passenger door', + 'friendly_name': 'Test Driver seat belt', }), 'context': , - 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'entity_id': 'binary_sensor.test_driver_seat_belt', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -849,8 +842,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_occupied', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -860,32 +853,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rear passenger window', + 'original_name': 'Driver seat occupied', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_rp_window', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rp_window', + 'translation_key': 'driver_seat_occupied', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-state] +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear passenger window', + 'friendly_name': 'Test Driver seat occupied', }), 'context': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'entity_id': 'binary_sensor.test_driver_seat_occupied', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -896,8 +888,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -909,29 +901,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Scheduled charging pending', + 'original_name': 'Emergency lane departure avoidance', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_scheduled_charging_pending', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_scheduled_charging_pending', + 'translation_key': 'emergency_lane_departure_avoidance', + 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-state] +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Scheduled charging pending', + 'friendly_name': 'Test Emergency lane departure avoidance', }), 'context': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_status-entry] +# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -943,7 +935,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_status', + 'entity_id': 'binary_sensor.test_european_vehicle', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -953,32 +945,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Status', + 'original_name': 'European vehicle', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'state', - 'unique_id': 'LRW3F7EK4NC700000-state', + 'translation_key': 'europe_vehicle', + 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_status-state] +# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Status', + 'friendly_name': 'Test European vehicle', }), 'context': , - 'entity_id': 'binary_sensor.test_status', + 'entity_id': 'binary_sensor.test_european_vehicle', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -989,8 +980,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fast_charger_present', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1000,13 +991,1081 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Tire pressure warning front left', + 'original_name': 'Fast charger present', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'translation_key': 'fast_charger_present', + 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_df', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_df', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pf', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_gps_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GPS state', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gps_state', + 'unique_id': 'LRW3F7EK4NC700000-gps_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + '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': 'Guest mode enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'guest_mode_enabled', + 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_homelink_nearby', + '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': 'Homelink nearby', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink_nearby', + 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_offroad_lightbar', + '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': 'Offroad lightbar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'offroad_lightbar_present', + 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + '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': 'Passenger seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + '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': 'Pin to drive enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pin_to_drive_enabled', + 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning', + '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': 'Preconditioning', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_is_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_preconditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + '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': 'Preconditioning enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_rear_display_hvac', + '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': 'Rear display HVAC', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_display_hvac_enabled', + 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_right_hand_drive', + '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': 'Right hand drive', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'right_hand_drive', + 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + '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': 'Scheduled charging pending', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_service_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': 'Service mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_mode', + 'unique_id': 'LRW3F7EK4NC700000-service_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'LRW3F7EK4NC700000-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + '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': 'Supercharger session trip planner', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercharger_session_trip_planner', + 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fl', 'unit_of_measurement': None, }) @@ -1194,25 +2253,72 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_trip_charging', - 'unique_id': 'LRW3F7EK4NC700000-charge_state_trip_charging', + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_is_user_present', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_trip_charging-state] +# name: test_binary_sensor[binary_sensor.test_user_present-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Trip charging', + 'device_class': 'presence', + 'friendly_name': 'Test User present', }), 'context': , - 'entity_id': 'binary_sensor.test_trip_charging', + 'entity_id': 'binary_sensor.test_user_present', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_user_present-entry] +# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1224,7 +2330,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_user_present', + 'entity_id': 'binary_sensor.test_wiper_heat', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1234,29 +2340,28 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'User present', + 'original_name': 'Wiper heat', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'vehicle_state_is_user_present', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_is_user_present', + 'translation_key': 'wiper_heat_enabled', + 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_user_present-state] +# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'presence', - 'friendly_name': 'Test User present', + 'friendly_name': 'Test Wiper heat', }), 'context': , - 'entity_id': 'binary_sensor.test_user_present', + 'entity_id': 'binary_sensor.test_wiper_heat', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] @@ -1311,6 +2416,32 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1322,17 +2453,56 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_actively_cooling-statealt] +# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1353,6 +2523,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1363,7 +2546,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -1380,6 +2563,97 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1391,7 +2665,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -1405,7 +2679,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -1419,7 +2693,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -1433,7 +2707,86 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -1459,7 +2812,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -1473,7 +2839,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -1487,7 +2853,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -1501,7 +2867,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -1515,7 +2881,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -1528,7 +2907,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -1545,6 +2937,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1628,3 +3033,31 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_window-state] + 'on' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'on' +# --- diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 24e1b02a5f8c7f..8364f2a6a6ea2a 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -335,54 +335,6 @@ 'state': 'open', }) # --- -# name: test_cover_alt[cover.test_sunroof-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.test_sunroof', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Sunroof', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_cover_alt[cover.test_sunroof-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Sunroof', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_sunroof', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_cover_alt[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -719,3 +671,39 @@ 'state': 'closed', }) # --- +# name: test_cover_streaming[cover.test_charge_port_door-closed] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-open] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-unknown] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index ac4c388873f4c2..0bc371b2d2d0bc 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -133,3 +133,21 @@ 'state': 'not_home', }) # --- +# name: test_device_tracker_streaming[device_tracker.test_location-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_location-state] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-restore] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-state] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-state] + 'home' +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 3b96d6f70c0d8a..16cabfddd09a65 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -352,7 +352,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_car_version': '2024.44.25 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index acff157bfea019..6439e74eecc6bd 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2414,6 +2414,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -3843,7 +3846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_speed-statealt] @@ -3859,7 +3862,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] @@ -3910,7 +3913,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-statealt] @@ -3926,7 +3929,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_time_to_arrival-entry] @@ -4977,3 +4980,24 @@ 'state': 'unknown', }) # --- +# name: test_sensors_streaming[sensor.test_battery_level-state] + '90' +# --- +# name: test_sensors_streaming[sensor.test_charge_cable-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_charge_energy_added-state] + '10' +# --- +# name: test_sensors_streaming[sensor.test_charger_power-state] + '2' +# --- +# name: test_sensors_streaming[sensor.test_charging-state] + 'charging' +# --- +# name: test_sensors_streaming[sensor.test_time_to_arrival-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_time_to_full_charge-state] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 0777f4ccdb9e06..2411d047135d9d 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -40,7 +40,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', + 'installed_version': '2024.44.25', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_update_alt[update.test_update-entry] @@ -98,8 +98,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', - 'latest_version': '2023.44.30.8', + 'installed_version': '2024.44.25', + 'latest_version': '2024.44.25', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0a47dce95373c8..5a7126afe1b778 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -5,6 +5,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -48,3 +49,58 @@ async def test_binary_sensor_refresh( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "INVALID_VALUE", + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + Signal.DRIVER_SEAT_BELT: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_front_driver_window", + "binary_sensor.test_front_passenger_window", + "binary_sensor.test_front_driver_door", + "binary_sensor.test_front_passenger_door", + "binary_sensor.test_driver_seat_belt", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 04edf6687650a6..75f94342f1e080 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -29,6 +29,7 @@ async def test_button( @pytest.mark.parametrize( ("name", "func"), [ + ("wake", "wake_up"), ("flash_lights", "flash_lights"), ("honk_horn", "honk_horn"), ("keyless_driving", "remote_start_drive"), diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 7dbdcfa5747e2c..14af1e732febe9 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -4,6 +4,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -25,6 +26,7 @@ async def test_cover( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" @@ -38,6 +40,7 @@ async def test_cover_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct with alternate values.""" @@ -52,6 +55,7 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -215,3 +219,127 @@ async def test_cover_services( state = hass.states.get(entity_id) assert state assert state.state == CoverState.CLOSED + + +async def test_cover_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateClosed", + Signal.FP_WINDOW: "WindowStateClosed", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStateClosed", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": False, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-closed") + + # Send some alternative data with everything open + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "WindowStateOpened", + Signal.RD_WINDOW: "WindowStateOpened", + Signal.RP_WINDOW: "WindowStateOpened", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": True, + "PassengerFront": True, + "PassengerRear": True, + "TrunkFront": True, + "TrunkRear": True, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get new values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-open") + + # Send some alternative data with everything unknown + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateUnknown", + Signal.FP_WINDOW: "WindowStateUnknown", + Signal.RD_WINDOW: "WindowStateUnknown", + Signal.RP_WINDOW: "WindowStateUnknown", + Signal.CHARGE_PORT_DOOR_OPEN: None, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": None, + "DriverRear": None, + "PassengerFront": None, + "PassengerRear": None, + "TrunkFront": None, + "TrunkRear": None, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get UNKNOWN values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unknown") diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index d86c3ca8596227..38a28092d33ac8 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,10 +14,12 @@ from .const import VEHICLE_DATA_ALT +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" @@ -23,14 +27,71 @@ async def test_device_tracker( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the device tracker entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCATION: { + "latitude": 1.0, + "longitude": 2.0, + }, + Signal.DESTINATION_LOCATION: { + "latitude": 3.0, + "longitude": 4.0, + }, + Signal.DESTINATION_NAME: "Home", + Signal.ORIGIN_LOCATION: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-restore") diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 6d4e04c21b4bd1..5481e6cc034712 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -142,13 +142,13 @@ async def test_energy_history_refresh_error( async def test_vehicle_stream( hass: HomeAssistant, - mock_listen: AsyncMock, + mock_add_listener: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test vehicle stream events.""" - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - mock_listen.assert_called_once() + await setup_platform(hass, [Platform.BINARY_SENSOR]) + mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_ON @@ -156,29 +156,37 @@ async def test_vehicle_stream( state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF - runtime_data: TeslemetryData = entry.runtime_data - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "vehicle_data": VEHICLE_DATA_ALT["response"], - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "vehicle_data": VEHICLE_DATA_ALT["response"], + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "state": "offline", - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "state": "offline", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_OFF + + +async def test_no_live_status( + hass: HomeAssistant, + mock_live_status: AsyncMock, +) -> None: + """Test coordinator refresh with an error.""" + mock_live_status.side_effect = AsyncMock({"response": ""}) + await setup_platform(hass) + + assert hass.states.get("sensor.energy_site_grid_power") is None diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f0b472a7183253..a488ebc8a06ec6 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,10 +1,11 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -25,11 +26,15 @@ async def test_sensors( freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, ) -> None: - """Tests that the sensor entities are correct.""" + """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") - entry = await setup_platform(hass, [Platform.SENSOR]) + # Force the vehicle to use polling + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ): + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -40,3 +45,54 @@ async def test_sensors( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + Signal.BATTERY_LEVEL: 90, + Signal.AC_CHARGING_ENERGY_IN: 10, + Signal.AC_CHARGING_POWER: 2, + Signal.CHARGING_CABLE_TYPE: None, + Signal.TIME_TO_FULL_CHARGE: 10, + Signal.MINUTES_TO_ARRIVAL: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "sensor.test_charging", + "sensor.test_battery_level", + "sensor.test_charge_energy_added", + "sensor.test_charger_power", + "sensor.test_charge_cable", + "sensor.test_time_to_full_charge", + "sensor.test_time_to_arrival", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 49a53fd327ca0a..02a8f22b6eab9f 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -112,4 +112,4 @@ async def test_errors(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR["reason"]}" + assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR['reason']}" diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index c31a1937d45f76..7feefdafedf900 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,11 +3,12 @@ from ipaddress import ip_address from unittest.mock import patch -from homeassistant.components import thread, zeroconf +from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( +TEST_ZEROCONF_RECORD = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 73382944cf0db6..e918edf70a47a5 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -5,14 +5,14 @@ import pytest from tololib import ToloCommunicationError -from homeassistant.components import dhcp from homeassistant.components.tolo.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index e322cf9f5de492..23e36eacdd570e 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -6,173 +6,35 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from kasa import ( - BaseProtocol, - Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, - DeviceType, - Feature, - KasaException, - Module, -) +from kasa import BaseProtocol, Device, DeviceType, Feature, KasaException, Module from kasa.interfaces import Fan, Light, LightEffect, LightState from kasa.smart.modules.alarm import Alarm from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.tplink import ( - CONF_AES_KEYS, - CONF_ALIAS, - CONF_CAMERA_CREDENTIALS, - CONF_CONNECTION_PARAMETERS, - CONF_CREDENTIALS_HASH, - CONF_HOST, - CONF_LIVE_VIEW, - CONF_MODEL, - CONF_USES_HTTP, - Credentials, -) from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_value_fixture - -ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) # noqa: PYI024 - -MODULE = "homeassistant.components.tplink" -MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" -IP_ADDRESS = "127.0.0.1" -IP_ADDRESS2 = "127.0.0.2" -IP_ADDRESS3 = "127.0.0.3" -ALIAS = "My Bulb" -ALIAS_CAMERA = "My Camera" -MODEL = "HS100" -MODEL_CAMERA = "C210" -MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" -DEVICE_ID = "123456789ABCDEFGH" -DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" -DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") -MAC_ADDRESS2 = "11:22:33:44:55:66" -MAC_ADDRESS3 = "66:55:44:33:22:11" -DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" -DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" -CREDENTIALS_HASH_LEGACY = "" -CONN_PARAMS_LEGACY = DeviceConnectionParameters( - DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor -) -DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) -DEVICE_CONFIG_DICT_LEGACY = { - k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" -} -CREDENTIALS = Credentials("foo", "bar") -CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" -CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" -CONN_PARAMS_KLAP = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap -) -DEVICE_CONFIG_KLAP = DeviceConfig( +from .const import ( + ALIAS, + CREDENTIALS_HASH_LEGACY, + DEVICE_CONFIG_LEGACY, + DEVICE_ID, IP_ADDRESS, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_KLAP, - uses_http=True, -) -CONN_PARAMS_AES = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes -) -_test_privkey = ( - "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" - "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" - "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" - "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" - "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" - "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" - "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" - "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" - "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" - "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" - "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" - "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" -) -_test_pubkey = ( - "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" - "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" - "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" -) -AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} -DEVICE_CONFIG_AES = DeviceConfig( - IP_ADDRESS2, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES, - uses_http=True, - aes_keys=AES_KEYS, -) -CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( - DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 -) -DEVICE_CONFIG_AES_CAMERA = DeviceConfig( - IP_ADDRESS3, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES_CAMERA, - uses_http=True, + MAC_ADDRESS, + MODEL, ) -DEVICE_CONFIG_DICT_KLAP = { - k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" -} -DEVICE_CONFIG_DICT_AES = { - k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" -} -CREATE_ENTRY_DATA_LEGACY = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), - CONF_USES_HTTP: False, -} +from tests.common import MockConfigEntry, load_json_value_fixture -CREATE_ENTRY_DATA_KLAP = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), - CONF_USES_HTTP: True, -} -CREATE_ENTRY_DATA_AES = { - CONF_HOST: IP_ADDRESS2, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), - CONF_USES_HTTP: True, - CONF_AES_KEYS: AES_KEYS, -} -CREATE_ENTRY_DATA_AES_CAMERA = { - CONF_HOST: IP_ADDRESS3, - CONF_ALIAS: ALIAS_CAMERA, - CONF_MODEL: MODEL_CAMERA, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), - CONF_USES_HTTP: True, - CONF_LIVE_VIEW: True, - CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, -} -SMALLEST_VALID_JPEG = ( - "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" - "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" - "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" -) -SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) +ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) # noqa: PYI024 def _load_feature_fixtures(): @@ -201,7 +63,7 @@ async def setup_platform_for_device( _patch_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) # Good practice to wait background tasks in tests see PR #112726 await hass.async_block_till_done(wait_background_tasks=True) @@ -217,15 +79,15 @@ async def snapshot_platform( device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) assert device_entries for device_entry in device_entries: - assert device_entry == snapshot( - name=f"{device_entry.name}-entry" - ), f"device entry snapshot failed for {device_entry.name}" + assert device_entry == snapshot(name=f"{device_entry.name}-entry"), ( + f"device entry snapshot failed for {device_entry.name}" + ) entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) translations = await async_get_translations(hass, "en", "entity", [DOMAIN]) unique_device_classes = [] @@ -237,18 +99,18 @@ async def snapshot_platform( if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) - assert ( - (key in translations) or single_device_class_translation - ), f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" - assert entity_entry == snapshot( - name=f"{entity_entry.entity_id}-entry" - ), f"entity entry snapshot failed for {entity_entry.entity_id}" + assert (key in translations) or single_device_class_translation, ( + f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry"), ( + f"entity entry snapshot failed for {entity_entry.entity_id}" + ) if entity_entry.disabled_by is None: state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot( - name=f"{entity_entry.entity_id}-state" - ), f"state snapshot failed for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state"), ( + f"state snapshot failed for {entity_entry.entity_id}" + ) async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None: @@ -380,12 +242,12 @@ def _mocked_feature( feature.name = name or id.upper() feature.set_value = AsyncMock() if not (fixture := FEATURES_FIXTURE.get(id)): - assert ( - require_fixture is False - ), f"No fixture defined for feature {id} and require_fixture is True" - assert ( - value is not UNDEFINED - ), f"Value must be provided if feature {id} not defined in features.json" + assert require_fixture is False, ( + f"No fixture defined for feature {id} and require_fixture is True" + ) + assert value is not UNDEFINED, ( + f"Value must be provided if feature {id} not defined in features.json" + ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} elif value is not UNDEFINED: fixture["value"] = value @@ -456,12 +318,12 @@ def _mocked_light_effect_module(device) -> LightEffect: effect.effect_list = ["Off", "Effect1", "Effect2"] async def _set_effect(effect_name, *_, **__): - assert ( - effect_name in effect.effect_list - ), f"set_effect '{effect_name}' not in {effect.effect_list}" - assert device.modules[ - Module.Light - ], "Need a light module to test set_effect method" + assert effect_name in effect.effect_list, ( + f"set_effect '{effect_name}' not in {effect.effect_list}" + ) + assert device.modules[Module.Light], ( + "Need a light module to test set_effect method" + ) device.modules[Module.Light].state.light_on = True effect.effect = effect_name diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index f1bbb80b80c96f..19cd5aa9acf8af 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -10,7 +10,8 @@ from homeassistant.components.tplink import DOMAIN from homeassistant.core import HomeAssistant -from . import ( +from . import _mocked_device +from .const import ( ALIAS_CAMERA, CREATE_ENTRY_DATA_AES_CAMERA, CREATE_ENTRY_DATA_LEGACY, @@ -26,7 +27,6 @@ MAC_ADDRESS2, MAC_ADDRESS3, MODEL_CAMERA, - _mocked_device, ) from tests.common import MockConfigEntry @@ -115,8 +115,12 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_init() -> Generator[AsyncMock]: - """Override async_setup_entry.""" +def mock_init() -> Generator[dict[str, AsyncMock]]: + """Override async_setup and async_setup_entry. + + This fixture must be declared before the hass fixture to avoid errors + in the logs during teardown of the hass fixture which calls async_unload. + """ with patch.multiple( "homeassistant.components.tplink", async_setup=DEFAULT, diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py new file mode 100644 index 00000000000000..57829a7aa34619 --- /dev/null +++ b/tests/components/tplink/const.py @@ -0,0 +1,146 @@ +"""Constants for the tplink component tests.""" + +from kasa import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from homeassistant.components.tplink import ( + CONF_AES_KEYS, + CONF_ALIAS, + CONF_CAMERA_CREDENTIALS, + CONF_CONNECTION_PARAMETERS, + CONF_CREDENTIALS_HASH, + CONF_HOST, + CONF_LIVE_VIEW, + CONF_MODEL, + CONF_USES_HTTP, + Credentials, +) + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" +IP_ADDRESS3 = "127.0.0.3" +ALIAS = "My Bulb" +ALIAS_CAMERA = "My Camera" +MODEL = "HS100" +MODEL_CAMERA = "C210" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEVICE_ID = "123456789ABCDEFGH" +DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" +DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") +MAC_ADDRESS2 = "11:22:33:44:55:66" +MAC_ADDRESS3 = "66:55:44:33:22:11" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" +CREDENTIALS_HASH_LEGACY = "" +CONN_PARAMS_LEGACY = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor +) +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = { + k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" +} +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" +CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" +CONN_PARAMS_KLAP = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap +) +DEVICE_CONFIG_KLAP = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_KLAP, + uses_http=True, +) +CONN_PARAMS_AES = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes +) +_test_privkey = ( + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" + "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" + "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" + "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" + "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" + "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" + "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" + "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" + "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" + "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" + "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" + "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" +) +_test_pubkey = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" + "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" + "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" +) +AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} +DEVICE_CONFIG_AES = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES, + uses_http=True, + aes_keys=AES_KEYS, +) +CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 +) +DEVICE_CONFIG_AES_CAMERA = DeviceConfig( + IP_ADDRESS3, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES_CAMERA, + uses_http=True, +) + +DEVICE_CONFIG_DICT_KLAP = { + k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" +} +DEVICE_CONFIG_DICT_AES = { + k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" +} +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), + CONF_USES_HTTP: False, +} + +CREATE_ENTRY_DATA_KLAP = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), + CONF_USES_HTTP: True, +} +CREATE_ENTRY_DATA_AES = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), + CONF_USES_HTTP: True, + CONF_AES_KEYS: AES_KEYS, +} +CREATE_ENTRY_DATA_AES_CAMERA = { + CONF_HOST: IP_ADDRESS3, + CONF_ALIAS: ALIAS_CAMERA, + CONF_MODEL: MODEL_CAMERA, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), + CONF_USES_HTTP: True, + CONF_LIVE_VIEW: True, + CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, +} +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 4a1cfe5b4110dd..e16d440951161f 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_states[binary_sensor.my_device_battery_low-entry] +# name: test_states[binary_sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': , 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.my_device_battery_low', + 'entity_id': 'binary_sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery low', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 739f02e51f0f99..461e8c6e50597d 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -115,7 +115,7 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- -# name: test_states[sensor.my_device_battery_level-entry] +# name: test_states[sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,7 +129,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -141,7 +141,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -150,16 +150,16 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_battery_level-state] +# name: test_states[sensor.my_device_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'my_device Battery level', + 'friendly_name': 'my_device Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tplink/test_binary_sensor.py b/tests/components/tplink/test_binary_sensor.py index e2b9cd08d13262..b487fa51baf1e6 100644 --- a/tests/components/tplink/test_binary_sensor.py +++ b/tests/components/tplink/test_binary_sensor.py @@ -4,18 +4,14 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.binary_sensor import BINARY_SENSOR_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -24,6 +20,7 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -47,7 +44,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor states.""" features = {description.key for description in BINARY_SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -68,7 +65,7 @@ async def test_binary_sensor( entity_registry: er.EntityRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor unique ids.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -77,7 +74,7 @@ async def test_binary_sensor( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -93,7 +90,7 @@ async def test_binary_sensor_children( device_registry: dr.DeviceRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor children.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -105,7 +102,7 @@ async def test_binary_sensor_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "binary_sensor.my_plug_overheated" diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py index a3eb89503361d7..c36d08337a7190 100644 --- a/tests/components/tplink/test_button.py +++ b/tests/components/tplink/test_button.py @@ -4,7 +4,6 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.tplink.button import BUTTON_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN @@ -16,11 +15,8 @@ entity_registry as er, issue_registry as ir, ) -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -30,6 +26,7 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -83,7 +80,7 @@ def create_entry(device_name, key): @pytest.fixture def mocked_feature_button() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink button feature.""" return _mocked_feature( "test_alarm", value="", @@ -101,7 +98,7 @@ async def test_states( snapshot: SnapshotAssertion, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button states.""" features = {description.key for description in BUTTON_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -118,14 +115,15 @@ async def test_states( async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button unique ids.""" mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -139,11 +137,12 @@ async def test_button_children( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, create_deprecated_child_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button children.""" mocked_feature = mocked_feature_button plug = _mocked_device( alias="my_device", @@ -151,7 +150,7 @@ async def test_button_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -173,6 +172,7 @@ async def test_button_children( async def test_button_press( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: @@ -180,7 +180,7 @@ async def test_button_press( mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -213,7 +213,7 @@ async def test_button_not_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not entity_registry.async_get(entity_id) @@ -265,7 +265,7 @@ async def test_button_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity = entity_registry.async_get(entity_id) diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py index aa83ae659fb194..ceb74e3a61a0d3 100644 --- a/tests/components/tplink/test_camera.py +++ b/tests/components/tplink/test_camera.py @@ -11,6 +11,9 @@ from homeassistant.components import stream from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, CameraEntityFeature, StreamType, async_get_image, @@ -23,15 +26,8 @@ from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - DEVICE_ID, - IP_ADDRESS3, - MAC_ADDRESS3, - SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - setup_platform_for_device, - snapshot_platform, -) +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID, IP_ADDRESS3, MAC_ADDRESS3, SMALLEST_VALID_JPEG_BYTES from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -44,7 +40,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test states.""" + """Test camera states.""" mock_camera_config_entry.add_to_hass(hass) mock_device = _mocked_device( @@ -73,6 +69,7 @@ async def test_camera_unique_id( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test camera unique id.""" mock_device = _mocked_device( @@ -92,14 +89,13 @@ async def test_camera_unique_id( ) assert device_entries entity_id = "camera.my_camera_live_view" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{DEVICE_ID}-live_view" async def test_handle_mjpeg_stream( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test handle_async_mjpeg_stream.""" mock_device = _mocked_device( @@ -126,7 +122,6 @@ async def test_handle_mjpeg_stream( async def test_handle_mjpeg_stream_not_supported( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test handle_async_mjpeg_stream.""" mock_device = _mocked_device( @@ -216,7 +211,7 @@ async def test_no_camera_image_when_streaming( mock_camera_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test no camera image when streaming.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -272,9 +267,8 @@ async def _get_stream(): async def test_no_concurrent_camera_image( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test async_get_image doesn't make concurrent requests.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -321,7 +315,7 @@ async def test_camera_image_auth_error( mock_connect: AsyncMock, mock_discovery: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test async_get_image auth error.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -367,7 +361,7 @@ async def test_camera_stream_source( mock_camera_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: - """Test async_get_image. + """Test camera stream source. This test would fail if the integration didn't properly put stream in the dependencies. @@ -444,16 +438,16 @@ async def test_camera_turn_on_off( assert state is not None await hass.services.async_call( - "camera", - "turn_on", + CAMERA_DOMAIN, + SERVICE_TURN_ON, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) mock_camera.set_state.assert_called_with(True) await hass.services.async_call( - "camera", - "turn_off", + CAMERA_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index 3a54048e1d64e9..b1c8abd3a9b2b1 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -27,12 +27,12 @@ from homeassistant.util import dt as dt_util from . import ( - DEVICE_ID, _mocked_device, _mocked_feature, setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -41,7 +41,7 @@ @pytest.fixture async def mocked_hub(hass: HomeAssistant) -> Device: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink hub.""" features = [ _mocked_feature( @@ -166,7 +166,8 @@ async def test_set_hvac_mode( ) mocked_state.set_value.assert_called_with(True) - with pytest.raises(ServiceValidationError): + msg = "Tried to set unsupported mode: dry" + with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 14f1260e2ec0b7..b093847869e707 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -7,7 +7,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, stream +from homeassistant.components import stream from homeassistant.components.tplink import ( DOMAIN, AuthenticationError, @@ -36,8 +36,11 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import ( +from . import _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery +from .conftest import override_side_effect +from .const import ( AES_KEYS, ALIAS, ALIAS_CAMERA, @@ -67,12 +70,7 @@ MODEL_CAMERA, MODULE, SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry @@ -168,8 +166,11 @@ async def test_discovery( assert result2["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_init") async def test_discovery_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -228,8 +229,11 @@ async def test_discovery_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_pick_device_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -293,8 +297,11 @@ async def test_discovery_pick_device_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery.""" mock_device = _mocked_device( @@ -336,8 +343,11 @@ async def test_discovery_auth( assert result2["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -407,13 +417,13 @@ async def test_discovery_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_errors( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test handling of discovery authentication errors. @@ -465,10 +475,10 @@ async def test_discovery_auth_errors( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -514,10 +524,10 @@ async def test_discovery_new_credentials( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials_invalid( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -977,11 +987,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: assert result["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_manual_auth( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -1083,14 +1093,14 @@ async def test_manual_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_manual_auth_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test manually setup auth errors.""" result = await hass.config_entries.flow.async_init( @@ -1150,9 +1160,9 @@ async def test_manual_port_override( hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock, - host_str, - host, - port, + host_str: str, + host: str, + port: int, ) -> None: """Test manually setup.""" config = DeviceConfig( @@ -1282,7 +1292,7 @@ def is_matching(self, other_flow) -> bool: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1296,7 +1306,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -1312,7 +1322,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -1326,7 +1336,7 @@ def is_matching(self, other_flow) -> bool: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1342,7 +1352,7 @@ def is_matching(self, other_flow) -> bool: ], ) async def test_discovered_by_dhcp_or_discovery( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we can setup when discovered from dhcp or discovery.""" @@ -1380,7 +1390,7 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1396,7 +1406,7 @@ async def test_discovered_by_dhcp_or_discovery( ], ) async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" @@ -1419,7 +1429,7 @@ async def test_integration_discovery_with_ip_change( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test reauth flow.""" + """Test integration updates ip address from discovery.""" mock_config_entry.add_to_hass(hass) with ( patch("homeassistant.components.tplink.Discover.discover", return_value={}), @@ -1597,7 +1607,7 @@ async def test_dhcp_discovery_with_ip_change( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1622,7 +1632,7 @@ async def test_dhcp_discovery_discover_fail( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1670,7 +1680,7 @@ async def test_reauth_camera( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test reauth flow on invalid camera credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS3] mock_camera_config_entry.add_to_hass(hass) mock_camera_config_entry.async_start_reauth( @@ -1762,7 +1772,7 @@ async def test_reauth_try_connect_all_fail( override_side_effect(mock_discovery["discover_single"], TimeoutError), override_side_effect(mock_discovery["try_connect_all"], lambda *_, **__: None), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_USERNAME: "fake_username", @@ -1774,7 +1784,23 @@ async def test_reauth_try_connect_all_fail( IP_ADDRESS, credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() - assert result2["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} + + mock_discovery["try_connect_all"].reset_mock() + with ( + override_side_effect(mock_discovery["discover_single"], TimeoutError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["try_connect_all"].assert_called_once() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_update_with_encryption_change( @@ -2025,9 +2051,9 @@ async def test_reauth_errors( mock_added_config_entry: MockConfigEntry, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test reauth errors.""" mock_added_config_entry.async_start_reauth(hass) @@ -2089,8 +2115,8 @@ async def test_pick_device_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - expected_flow, + error_type: type[Exception], + expected_flow: FlowResultType, ) -> None: """Test errors on pick_device.""" result = await hass.config_entries.flow.async_init( @@ -2127,11 +2153,11 @@ async def test_pick_device_errors( assert result4["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2153,11 +2179,11 @@ async def test_discovery_timeout_try_connect_all( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_needs_creds( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2191,11 +2217,11 @@ async def test_discovery_timeout_try_connect_all_needs_creds( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_fail( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tplink/test_fan.py b/tests/components/tplink/test_fan.py index deba33abfa5fbc..13a768f683c8da 100644 --- a/tests/components/tplink/test_fan.py +++ b/tests/components/tplink/test_fan.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import timedelta - +from freezegun.api import FrozenDateTimeFactory from kasa import Device, Module from syrupy.assertion import SnapshotAssertion @@ -11,13 +10,15 @@ ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + 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 -import homeassistant.util.dt as dt_util -from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -56,6 +57,7 @@ async def test_fan_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test a fan unique id.""" fan = _mocked_device(modules=[Module.Fan], alias="my_fan") @@ -66,12 +68,16 @@ async def test_fan_unique_id( ) assert device_entries entity_id = "fan.my_fan" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID -async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: - """Test a color fan and that all transitions are correctly passed.""" +async def test_fan( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fan functionality.""" device = _mocked_device(modules=[Module.Fan], alias="my_fan") fan = device.modules[Module.Fan] fan.fan_speed_level = 0 @@ -83,26 +89,29 @@ async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> N assert state.state == "off" await hass.services.async_call( - FAN_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(4) fan.set_fan_speed_level.reset_mock() fan.fan_speed_level = 4 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "on" await hass.services.async_call( - FAN_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(0) fan.set_fan_speed_level.reset_mock() await hass.services.async_call( FAN_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, blocking=True, ) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 8dad8881b9b3d7..1fbd79c16c2a9f 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -8,7 +8,16 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module +from kasa import ( + AuthenticationError, + Device, + DeviceConfig, + DeviceType, + Feature, + KasaException, + Module, +) +from kasa.iot import IotStrip import pytest from homeassistant import setup @@ -38,6 +47,14 @@ from homeassistant.util import dt as dt_util from . import ( + _mocked_device, + _mocked_feature, + _patch_connect, + _patch_discovery, + _patch_single_discovery, +) +from .conftest import override_side_effect +from .const import ( ALIAS, CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, @@ -53,13 +70,7 @@ IP_ADDRESS, MAC_ADDRESS, MODEL, - _mocked_device, - _mocked_feature, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry, async_fire_time_changed @@ -98,7 +109,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) @@ -117,7 +128,7 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: _patch_single_discovery(no_device=True), _patch_connect(no_device=True), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -175,7 +186,7 @@ async def test_config_entry_wrong_mac_Address( ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -309,7 +320,7 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) device = _mocked_device(alias="my_plug", features=["state"]) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -355,7 +366,7 @@ async def test_update_attrs_fails_in_init( type(light_module).color_temp = p light.__str__ = lambda _: "MockLight" with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -388,7 +399,7 @@ async def test_update_attrs_fails_on_update( light_module = light.modules[Module.Light] with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -434,7 +445,7 @@ async def test_feature_no_category( ) dev.features["led"].category = Feature.Category.Unset with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug_led" @@ -501,7 +512,7 @@ async def test_unlink_devices( # Generate list of test identifiers test_identifiers = [ - (domain, f"{device_id}{"" if i == 0 else f"_000{i}"}") + (domain, f"{device_id}{'' if i == 0 else f'_000{i}'}") for i in range(id_count) for domain in domains ] @@ -825,3 +836,152 @@ async def _connect(config): assert entry.data == expected_entry_data assert "Migration to version 1.5 complete" in caplog.text + + +@pytest.mark.parametrize( + ("device_type"), + [ + (Device), + (IotStrip), + ], +) +@pytest.mark.parametrize( + ("platform", "feature_id", "translated_name"), + [ + pytest.param("switch", "led", "led", id="switch"), + pytest.param( + "sensor", "current_consumption", "current_consumption", id="sensor" + ), + pytest.param("binary_sensor", "overheated", "overheated", id="binary_sensor"), + pytest.param("number", "smooth_transition_on", "smooth_on", id="number"), + pytest.param("select", "light_preset", "light_preset", id="select"), + pytest.param("button", "reboot", "restart", id="button"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + feature_id: str, + translated_name: str, + device_type: type, +) -> None: + """Test for automatic device addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=[feature_id], + device_type=DeviceType.StripSocket, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=[feature_id], + device_type=DeviceType.Hub, + spec=device_type, + device_id="hub_parent", + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + entity_id = f"{platform}.child_2_{translated_name}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6549711b7fcfa0..565d4f1221ad7e 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import re from unittest.mock import MagicMock, PropertyMock from freezegun.api import FrozenDateTimeFactory @@ -19,6 +20,11 @@ import pytest from homeassistant.components import tplink +from homeassistant.components.homeassistant.scene import ( + CONF_SCENE_ID, + CONF_SNAPSHOT, + SERVICE_CREATE, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -34,8 +40,15 @@ ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.light import ( + SERVICE_RANDOM_EFFECT, + SERVICE_SEQUENCE_EFFECT, +) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -51,14 +64,13 @@ import homeassistant.util.dt as dt_util from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _patch_connect, _patch_discovery, _patch_single_discovery, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -83,7 +95,7 @@ async def test_light_unique_id( light = _mocked_device(modules=[Module.Light], alias="my_light") light.device_type = device_type with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -93,8 +105,11 @@ async def test_light_unique_id( ) -async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: - """Test a light unique id.""" +async def test_legacy_dimmer_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test dimmer unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -108,16 +123,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: light.device_type = DeviceType.Dimmer with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.parametrize( - ("device", "transition"), + ("device", "extra_data", "expected_transition"), [ ( _mocked_device( @@ -130,11 +145,12 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), - 2.0, + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, ), ( _mocked_device( - modules=[Module.Light, Module.LightEffect], + modules=[Module.Light], features=[ _mocked_feature("brightness", value=50), _mocked_feature("hsv", value=(10, 30, 5)), @@ -143,12 +159,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), + {}, None, ), ], ) async def test_color_light( - hass: HomeAssistant, device: MagicMock, transition: float | None + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, ) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( @@ -157,93 +177,214 @@ async def test_color_light( already_migrated_config_entry.add_to_hass(hass) light = device.modules[Module.Light] - # Setting color_temp to None emulates a device with active effects + # Setting color_temp to None emulates a device without color temp light.color_temp = None with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} + BASE_PAYLOAD |= extra_data + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + assert attributes.get(ATTR_EFFECT) is None + + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=False, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=True, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=expected_transition) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) + light.set_hsv.reset_mock() + + +@pytest.mark.parametrize( + ("device", "extra_data", "expected_transition"), + [ + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, + ), + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {}, + None, + ), + ], +) +async def test_color_light_with_active_effect( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, +) -> None: + """Test a color light and that all transitions are correctly passed.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + light = device.modules[Module.Light] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_bulb" - KASA_TRANSITION_VALUE = transition * 1_000 if transition is not None else None BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} - if transition: - BASE_PAYLOAD[ATTR_TRANSITION] = transition + BASE_PAYLOAD |= extra_data state = hass.states.get(entity_id) assert state.state == "on" attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + # If effect is active, only the brightness can be controlled - if attributes.get(ATTR_EFFECT) is not None: - assert attributes[ATTR_COLOR_MODE] == "brightness" - else: - assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 - assert attributes[ATTR_HS_COLOR] == (10, 30) - assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) - assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + assert attributes.get(ATTR_EFFECT) is not None + assert attributes[ATTR_COLOR_MODE] == "brightness" await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", BASE_PAYLOAD, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True ) light.set_state.assert_called_once_with( - LightState(light_on=False, transition=KASA_TRANSITION_VALUE) + LightState(light_on=False, transition=expected_transition) ) light.set_state.reset_mock() - await hass.services.async_call(LIGHT_DOMAIN, "turn_on", BASE_PAYLOAD, blocking=True) + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) light.set_state.assert_called_once_with( - LightState(light_on=True, transition=KASA_TRANSITION_VALUE) + LightState(light_on=True, transition=expected_transition) ) light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, blocking=True, ) - light.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) + light.set_brightness.assert_called_with(39, transition=expected_transition) light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - light.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) light.set_hsv.reset_mock() async def test_color_light_no_temp(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a color light with no color temp.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -258,7 +399,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: type(light).color_temp = PropertyMock(side_effect=Exception) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -274,20 +415,20 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -296,7 +437,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) @@ -304,51 +445,112 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: light.set_hsv.reset_mock() -@pytest.mark.parametrize( - ("device", "is_color"), - [ - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature("hsv", value=(10, 30, 5)), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], +async def test_color_temp_light_color(hass: HomeAssistant) -> None: + """Test a color temp light with color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 ), - True, - ), - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], + ], + ) + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + light = device.modules[Module.Light] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + light.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + light.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + +async def test_color_temp_light_no_color(hass: HomeAssistant) -> None: + """Test a color temp light with no color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 ), - False, - ), - ], -) -async def test_color_temp_light( - hass: HomeAssistant, device: MagicMock, is_color: bool -) -> None: - """Test a light.""" + ], + ) already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - # device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -358,29 +560,27 @@ async def test_color_temp_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" - if is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] - else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -389,7 +589,7 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) @@ -399,7 +599,7 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, blocking=True, ) @@ -409,7 +609,7 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, blocking=True, ) @@ -418,7 +618,7 @@ async def test_color_temp_light( async def test_brightness_only_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light brightness.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -430,7 +630,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -443,20 +643,20 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -465,7 +665,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: async def test_on_off_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light turns on and off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -474,7 +674,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -485,20 +685,20 @@ async def test_on_off_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() async def test_off_at_start_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light off at startup.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -509,7 +709,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -521,7 +721,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a dimmer turns on without brightness being set.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -532,7 +732,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -541,7 +741,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: assert state.state == "off" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once_with( LightState( @@ -582,7 +782,7 @@ async def test_smart_strip_effects( _patch_single_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -596,7 +796,7 @@ async def test_smart_strip_effects( # is in progress calls set_effect to clear the effect await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) @@ -607,7 +807,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) @@ -624,7 +824,7 @@ async def test_smart_strip_effects( # Test setting light effect off await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "off"}, blocking=True, ) @@ -639,7 +839,7 @@ async def test_smart_strip_effects( caplog.clear() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect3"}, blocking=True, ) @@ -668,7 +868,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -698,7 +898,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -708,7 +908,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -737,7 +937,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -787,7 +987,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -796,7 +996,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -839,6 +1039,84 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect.set_custom_effect.reset_mock() +@pytest.mark.parametrize( + ("service_name", "service_params", "expected_extra_params"), + [ + pytest.param( + SERVICE_SEQUENCE_EFFECT, + { + "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + { + "type": "sequence", + "sequence": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "repeat_times": 0, + "spread": 1, + "direction": 4, + }, + id="sequence", + ), + pytest.param( + SERVICE_RANDOM_EFFECT, + {"init_states": [340, 20, 50]}, + {"type": "random", "init_states": [[340, 20, 50]], "random_seed": 100}, + id="random", + ), + ], +) +async def test_smart_strip_effect_service_error( + hass: HomeAssistant, + service_name: str, + service_params: dict, + expected_extra_params: dict, +) -> None: + """Test smart strip effect service errors.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light_effect = device.modules[Module.LightEffect] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + light_effect.set_custom_effect.side_effect = KasaException("failed") + + base = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + } + expected_params = {**base, **expected_extra_params} + expected_msg = f"Error trying to set custom effect {expected_params}: failed" + + with pytest.raises(HomeAssistantError, match=re.escape(expected_msg)): + await hass.services.async_call( + DOMAIN, + service_name, + { + ATTR_ENTITY_ID: entity_id, + **service_params, + }, + blocking=True, + ) + + async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: """Test smart strip custom random effects at startup.""" already_migrated_config_entry = MockConfigEntry( @@ -852,7 +1130,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> light_effect = device.modules[Module.LightEffect] light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -862,7 +1140,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> # fallback to set HSV when custom effect is not known so it does turn back on await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -882,7 +1160,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -892,7 +1170,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "sequence_effect", + SERVICE_SEQUENCE_EFFECT, { ATTR_ENTITY_ID: entity_id, "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], @@ -957,7 +1235,7 @@ async def test_light_errors_when_turned_on( light.set_state.side_effect = exception_type(msg) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -968,7 +1246,7 @@ async def test_light_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert light.set_state.call_count == 1 @@ -1008,7 +1286,7 @@ async def test_light_child( ) with _patch_discovery(device=parent_device), _patch_connect(device=parent_device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_device" @@ -1049,14 +1327,16 @@ async def test_scene_effect_light( light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - assert await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) - assert await async_setup_component(hass, "scene", {}) + assert await hass.config_entries.async_setup( + already_migrated_config_entry.entry_id + ) + assert await async_setup_component(hass, SCENE_DOMAIN, {}) await hass.async_block_till_done() entity_id = "light.my_light" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1068,9 +1348,9 @@ async def test_scene_effect_light( assert state.attributes["effect"] is EFFECT_OFF await hass.services.async_call( - "scene", - "create", - {"scene_id": "effect_off_scene", "snapshot_entities": [entity_id]}, + SCENE_DOMAIN, + SERVICE_CREATE, + {CONF_SCENE_ID: "effect_off_scene", CONF_SNAPSHOT: [entity_id]}, blocking=True, ) await hass.async_block_till_done() @@ -1078,7 +1358,7 @@ async def test_scene_effect_light( assert scene_state.state is STATE_UNKNOWN await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1089,10 +1369,10 @@ async def test_scene_effect_light( assert state.state is STATE_OFF await hass.services.async_call( - "scene", - "turn_on", + SCENE_DOMAIN, + SERVICE_TURN_ON, { - "entity_id": "scene.effect_off_scene", + ATTR_ENTITY_ID: "scene.effect_off_scene", }, blocking=True, ) diff --git a/tests/components/tplink/test_number.py b/tests/components/tplink/test_number.py index 865ce27ffc0c01..07d64178dfa1a6 100644 --- a/tests/components/tplink/test_number.py +++ b/tests/components/tplink/test_number.py @@ -3,7 +3,6 @@ from kasa import Feature from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -15,11 +14,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -28,6 +24,7 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -39,7 +36,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a number states.""" features = {description.key for description in NUMBER_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -54,7 +51,7 @@ async def test_states( async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test a sensor unique ids.""" + """Test number unique ids.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -70,7 +67,7 @@ async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) - ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -84,7 +81,7 @@ async def test_number_children( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test a sensor unique ids.""" + """Test number children.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -104,7 +101,7 @@ async def test_number_children( children=_mocked_strip_children(features=[new_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -142,7 +139,7 @@ async def test_number_set( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" diff --git a/tests/components/tplink/test_select.py b/tests/components/tplink/test_select.py index 6c49185d91c81b..3b99412740aae8 100644 --- a/tests/components/tplink/test_select.py +++ b/tests/components/tplink/test_select.py @@ -4,7 +4,6 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,11 +15,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -29,13 +25,14 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @pytest.fixture def mocked_feature_select() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink select feature.""" return _mocked_feature( "light_preset", value="First choice", @@ -53,7 +50,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test select states.""" features = {description.key for description in SELECT_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +69,7 @@ async def test_select( entity_registry: er.EntityRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select unique ids.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -81,7 +78,7 @@ async def test_select( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -97,7 +94,7 @@ async def test_select_children( device_registry: dr.DeviceRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select children.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -109,7 +106,7 @@ async def test_select_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" @@ -141,7 +138,7 @@ async def test_select_select( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index a53b59df0dc6b3..857a2365527aa6 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -4,18 +4,14 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_energy_features, _mocked_feature, @@ -25,6 +21,7 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -36,7 +33,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a sensor states.""" features = {description.key for description in SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -67,7 +64,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: alias="my_bulb", modules=[Module.Light], features=["state", *emeter_features] ) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -104,7 +101,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: ) plug = _mocked_device(alias="my_plug", features=["state", *emeter_features]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -131,7 +128,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_device(alias="my_bulb", modules=[Module.Light]) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -167,7 +164,7 @@ async def test_sensor_unique_id( ) plug = _mocked_device(alias="my_plug", features=emeter_features) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() expected = { @@ -202,7 +199,7 @@ async def test_undefined_sensor( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() msg = ( @@ -240,7 +237,7 @@ async def test_sensor_children_on_parent( device_type=Device.Type.WallSwitch, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -288,7 +285,7 @@ async def test_sensor_children_on_child( device_type=Device.Type.Strip, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -308,19 +305,18 @@ async def test_sensor_children_on_child( assert child_device.via_device_id == device.id -@pytest.mark.skip -async def test_new_datetime_sensor( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_datetime_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a sensor unique ids.""" - # Skipped temporarily while datetime handling on hold. + """Test a timestamp sensor.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["on_since"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_on_since" diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index e9c8cc07b67cc2..bdf54f10e8bf69 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -9,7 +9,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.switch import SWITCH_DESCRIPTIONS @@ -25,12 +29,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_strip_children, _patch_connect, @@ -38,6 +39,7 @@ setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -49,7 +51,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a switch states.""" features = {description.key for description in SWITCH_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +74,7 @@ async def test_plug(hass: HomeAssistant) -> None: plug = _mocked_device(alias="my_plug", features=["state"]) feat = plug.features["state"] with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -80,13 +82,13 @@ async def test_plug(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -120,7 +122,7 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None feat = dev.features["led"] already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_name = slugify(dev.alias) @@ -131,13 +133,13 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None assert led_state.name == f"{dev.alias} LED" await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(False) feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(True) feat.set_value.reset_mock() @@ -153,7 +155,7 @@ async def test_plug_unique_id( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -168,7 +170,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -197,7 +199,7 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[0].features["state"].value = True strip.children[1].features["state"].value = False with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_strip_plug0" @@ -205,14 +207,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[0].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -222,14 +224,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_OFF await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[1].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -249,7 +251,7 @@ async def test_strip_unique_ids( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() for plug_id in range(2): @@ -260,9 +262,11 @@ async def test_strip_unique_ids( async def test_strip_blank_alias( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test a strip unique id.""" + """Test a strip with blank parent alias.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -274,14 +278,30 @@ async def test_strip_blank_alias( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() + strip_entity_id = "switch.unnamed_ks123" + state = hass.states.get(strip_entity_id) + assert state.name == "Unnamed KS123" + reg_ent = entity_registry.async_get(strip_entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + assert reg_dev.name == "Unnamed KS123" + for plug_id in range(2): entity_id = f"switch.unnamed_ks123_stripsocket_{plug_id + 1}" state = hass.states.get(entity_id) assert state.name == f"Unnamed KS123 Stripsocket {plug_id + 1}" + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + # Switch is a primary feature so entities go on the parent device. + assert reg_dev.name == "Unnamed KS123" + @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), @@ -320,7 +340,7 @@ async def test_plug_errors_when_turned_on( feat.set_value.side_effect = exception_type("test error") with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -331,7 +351,7 @@ async def test_plug_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert feat.set_value.call_count == 1 diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index b6f38b1d83d75c..6d6215a21abcdb 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -6,10 +6,13 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tradfri import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import TRADFRI_PATH @@ -115,13 +118,13 @@ async def test_discovery_connection( flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -150,13 +153,13 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.124"), ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -174,13 +177,13 @@ async def test_duplicate_discovery( result = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -190,13 +193,13 @@ async def test_duplicate_discovery( result2 = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -215,13 +218,13 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 234269cc9f8a56..9d7a38739578f5 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from pytrafikverket.models import TrainStopModel +from pytrafikverket import StationInfoModel, TrainStopModel from homeassistant.components.trafikverket_train.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -40,6 +40,9 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), ): await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -50,8 +53,8 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry.entry_id) @@ -61,8 +64,8 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: source=SOURCE_USER, data=ENTRY_CONFIG2, entry_id="2", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry2.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry2.entry_id) @@ -171,3 +174,57 @@ def fixture_get_train_stop() -> TrainStopModel: modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), product_description=["Regionaltåg"], ) + + +@pytest.fixture(name="get_train_stations") +def fixture_get_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ) + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ) + ], + ] + + +@pytest.fixture(name="get_multiple_train_stations") +def fixture_get_multiple_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ), + StationInfoModel( + signature="Csu", + station_name="Stockholm City", + advertised=True, + ), + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ), + StationInfoModel( + signature="Ups", + station_name="Uppsala City", + advertised=True, + ), + ], + ] diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index eac5e629bf0c19..241831b5553b76 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -5,14 +5,13 @@ from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import TrainStopModel from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -26,24 +25,27 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import ENTRY_CONFIG, OPTIONS_CONFIG + from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" assert result["errors"] == {} with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -67,8 +69,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == { "api_key": "1234567890", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "fri"], } @@ -76,7 +78,70 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_entry_already_exist(hass: HomeAssistant) -> None: +async def test_form_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stockholm C to Uppsala C at 10:00" + assert result["data"] == { + "api_key": "1234567890", + "name": "Stockholm C to Uppsala C at 10:00", + "from": "Csu", + "to": "Ups", + "time": "10:00", + "weekday": ["mon", "fri"], + } + assert result["options"] == {"filter_product": None} + + +async def test_form_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test flow aborts when entry already exist.""" entry = MockConfigEntry( @@ -84,14 +149,14 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, CONF_FILTER_PRODUCT: None, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -103,10 +168,11 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -130,28 +196,24 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", - ), - ( - MultipleTrainStationsFound, - "more_stations", + {"from": "invalid_station", "to": "invalid_station"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -159,16 +221,13 @@ async def test_flow_fails( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -179,24 +238,24 @@ async def test_flow_fails( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails_departures( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -204,19 +263,13 @@ async def test_flow_fails_departures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -227,23 +280,25 @@ async def test_flow_fails_departures( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -254,10 +309,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -275,8 +328,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -287,24 +340,27 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", + {"from": "invalid_station"}, ), ( - MultipleTrainStationsFound, - "more_stations", + UnknownError, + {"base": "cannot_connect"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -312,13 +368,13 @@ async def test_reauth_flow_error( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -326,12 +382,9 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -341,14 +394,12 @@ async def test_reauth_flow_error( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -366,8 +417,8 @@ async def test_reauth_flow_error( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -377,17 +428,20 @@ async def test_reauth_flow_error( ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error_departures( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -395,13 +449,13 @@ async def test_reauth_flow_error_departures( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -409,10 +463,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), ): @@ -424,11 +475,12 @@ async def test_reauth_flow_error_departures( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -449,8 +501,8 @@ async def test_reauth_flow_error_departures( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -460,6 +512,7 @@ async def test_options_flow( hass: HomeAssistant, get_trains: list[TrainStopModel], get_train_stop: TrainStopModel, + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( @@ -467,24 +520,28 @@ async def test_options_flow( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", return_value=get_trains, ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", return_value=get_train_stop, @@ -520,3 +577,328 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {"filter_product": None} + + +async def test_reconfigure_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we can reconfigure with multiple stations.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test flow aborts when entry already exist in a reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Cst", + CONF_TO: "U", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_FILTER_PRODUCT: None, + }, + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + InvalidAuthentication, + {"base": "invalid_auth"}, + ), + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + Exception, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + UnknownError, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails_departures( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index 41c8e2432efb16..cb048365700a46 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -4,8 +4,14 @@ from unittest.mock import patch -from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound -from pytrafikverket.models import TrainStopModel +import pytest +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + StationInfoModel, + TrainStopModel, + UnknownError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components.trafikverket_train.const import DOMAIN @@ -28,14 +34,14 @@ async def test_unload_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -65,13 +71,13 @@ async def test_auth_failed( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=InvalidAuthentication, ): await hass.config_entries.async_setup(entry.entry_id) @@ -96,13 +102,13 @@ async def test_no_stations( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=NoTrainStationFound, ): await hass.config_entries.async_setup(entry.entry_id) @@ -124,8 +130,8 @@ async def test_migrate_entity_unique_id( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -139,7 +145,7 @@ async def test_migrate_entity_unique_id( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -158,8 +164,9 @@ async def test_migrate_entity_unique_id( async def test_migrate_entry( hass: HomeAssistant, get_trains: list[TrainStopModel], + get_train_stations: list[StationInfoModel], ) -> None: - """Test migrate entry unique id.""" + """Test migrate entry.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -174,7 +181,11 @@ async def test_migrate_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -186,8 +197,18 @@ async def test_migrate_entry( assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 + # Migration to version 2.1 changed from/to to use station signatures + assert entry.data == { + "api_key": "1234567890", + "from": "Cst", + "to": "U", + "time": None, + "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "name": "Stockholm C to Uppsala C", + } + # Migration to version 1.2 removed unique_id assert entry.unique_id is None @@ -201,18 +222,73 @@ async def test_migrate_entry_from_future_version_fails( source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, - version=2, + version=3, + minor_version=1, + entry_id="1", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ("side_effect"), + [ + (InvalidAuthentication), + (NoTrainStationFound), + (UnknownError), + (Exception), + ], +) +async def test_migrate_entry_fails(hass: HomeAssistant, side_effect: Exception) -> None: + """Test migrate entry fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, entry_id="1", ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_entry_fails_multiple_stations( + hass: HomeAssistant, + get_multiple_train_stations: list[StationInfoModel], +) -> None: + """Test migrate entry fails on multiple stations found.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", - return_value=get_trains, + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, ), ): await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0b01a24720d407..2159d92ae4bea8 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -22,7 +22,6 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -64,7 +63,6 @@ async def test_default_entity_attributes() -> None: entity = DefaultEntity() assert entity.hass is None - assert entity.name is UNDEFINED assert entity.default_language == DEFAULT_LANG assert entity.supported_languages == SUPPORT_LANGUAGES assert entity.supported_options is None diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2b61b26fe0c481..352c5249b0b98a 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -4,12 +4,12 @@ import pytest -from homeassistant.components import dhcp from homeassistant.components.twinkly.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_MAC, TEST_MODEL, TEST_NAME @@ -95,7 +95,7 @@ async def test_dhcp_full_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -127,7 +127,7 @@ async def test_dhcp_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -146,7 +146,7 @@ async def test_user_flow_works_discovery(hass: HomeAssistant) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 798b613b18dc5f..702f8629219ca3 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -174,6 +174,7 @@ def fixture_request( dpi_group_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], + traffic_route_payload: list[dict[str, Any]], site_payload: list[dict[str, Any]], system_information_payload: list[dict[str, Any]], wlan_payload: list[dict[str, Any]], @@ -214,6 +215,7 @@ def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) mock_get_request(f"/v2/api/site/{site_id}/trafficrules", traffic_rule_payload) + mock_get_request(f"/v2/api/site/{site_id}/trafficroutes", traffic_route_payload) return __mock_requests @@ -291,6 +293,12 @@ def traffic_rule_payload_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="traffic_route_payload") +def traffic_route_payload_data() -> list[dict[str, Any]]: + """Traffic route data.""" + return [] + + @pytest.fixture(name="wlan_payload") def fixture_wlan_data() -> list[dict[str, Any]]: """WLAN data.""" diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index fc3aeccea9fcd6..6a493e32b02c62 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -262,7 +262,7 @@ async def test_device_button_entities( WLAN_REGENERATE_PASSWORD, "button.ssid_1_regenerate_password", "put", - f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]["_id"]}", + f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]['_id']}", { "json": {"data": "password changed successfully", "meta": {"rc": "ok"}}, "headers": {"content-type": CONTENT_TYPE_JSON}, diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 71b196550daf9c..9d85dedbc9aad4 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.unifi.config_flow import _async_discover_unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -33,6 +32,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ConfigEntryFactoryType @@ -482,7 +482,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.208.1:41417/rootDesc.xml", @@ -522,7 +522,7 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -544,7 +544,7 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -570,7 +570,7 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index c653370656dc16..b37e4f47137705 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -589,14 +589,14 @@ async def test_restoring_client( entity_registry.async_get_or_create( # Make sure unique ID converts to site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'{clients_all_payload[0]["mac"]}-site_id', + f"{clients_all_payload[0]['mac']}-site_id", suggested_object_id=clients_all_payload[0]["hostname"], config_entry=config_entry, ) entity_registry.async_get_or_create( # Unique ID already follow format site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'site_id-{client_payload[0]["mac"]}', + f"site_id-{client_payload[0]['mac']}", suggested_object_id=client_payload[0]["hostname"], config_entry=config_entry, ) diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ef93afa7e3e021..e4765d1181e6af 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -809,6 +809,24 @@ "target_devices": [{"client_mac": CLIENT_1["mac"], "type": "CLIENT"}], } +TRAFFIC_ROUTE = { + "_id": "676f8dbb8f1d54503bba19ab", + "description": "Test traffic route", + "domains": [{"domain": "youtube.com", "port_ranges": [], "ports": []}], + "enabled": True, + "ip_addresses": [], + "ip_ranges": [], + "kill_switch_enabled": True, + "matching_target": "DOMAIN", + "network_id": "676f8d288f1d54503bba1987", + "next_hop": "", + "regions": [], + "target_devices": [ + {"network_id": "6060b00f45de3905133cea14", "type": "NETWORK"}, + {"network_id": "6060ae6045de3905133cea0a", "type": "NETWORK"}, + ], +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1154,6 +1172,60 @@ async def test_traffic_rules( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize(("traffic_route_payload"), [([TRAFFIC_ROUTE])]) +async def test_traffic_routes( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + traffic_route_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi traffic routes.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert hass.states.get("switch.unifi_network_test_traffic_route").state == STATE_ON + + traffic_route = deepcopy(traffic_route_payload[0]) + + # Disable traffic route + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/trafficroutes/{traffic_route['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + # Updating the value for traffic routes will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(traffic_route) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable traffic route + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + + expected_enable_call = deepcopy(traffic_route) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ @@ -1577,14 +1649,14 @@ async def test_updating_unique_id( entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[0]["mac"]}-outlet-1', + f"{device_payload[0]['mac']}-outlet-1", suggested_object_id="plug_outlet_1", config_entry=config_entry, ) entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[1]["mac"]}-poe-1', + f"{device_payload[1]['mac']}-poe-1", suggested_object_id="switch_port_1_poe", config_entry=config_entry, ) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 8bfdc004092503..0eae2a48fea16d 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -11,7 +11,6 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.unifiprotect.const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, @@ -23,6 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( DEVICE_HOSTNAME, @@ -37,13 +38,13 @@ from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), ) SSDP_DISCOVERY = ( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", @@ -338,7 +339,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - ], ) async def test_discovered_by_ssdp_or_dhcp( - hass: HomeAssistant, source: str, data: dhcp.DhcpServiceInfo | ssdp.SsdpServiceInfo + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo ) -> None: """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 724ed108673c35..85de85cd1c1656 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -95,42 +95,38 @@ async def test_light_update( async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn off.""" + """Test light entity turn on.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, - blocking=True, + "light", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(True, 3) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, True),) async def test_light_turn_off( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn on.""" + """Test light entity turn off.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + "light", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(False) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, False),) diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index 0f1b779168045a..f787089b83fe53 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -12,6 +12,7 @@ from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, async_generate_proxy_event_video_url, + async_generate_snapshot_url, async_generate_thumbnail_url, ) from homeassistant.core import HomeAssistant @@ -169,6 +170,231 @@ async def test_thumbnail_invalid_entry_entry_id( assert response.status == 404 +async def test_snapshot_bad_nvr_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad NVR id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(ufp.api.bootstrap.nvr.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(camera.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_perms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera perms.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + ufp.api.bootstrap.auth_user.all_permissions = [] + ufp.api.bootstrap.auth_user._perm_cache = {} + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 403 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_timestamp( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad timestamp params.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(fixed_now.replace(microsecond=0).isoformat(), "bad_time") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot_client_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot triggers client error at API.""" + + ufp.api.get_camera_snapshot = AsyncMock(side_effect=ClientError()) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +async def test_snapshot_notfound( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot not found.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=None) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +@pytest.mark.parametrize(("width", "height"), [("test", None), (None, "test")]) +async def test_snapshot_bad_params( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test invalid bad query parameters.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot at timestamp in URL.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # verify when height is None that it is called with camera high channel height + height = camera.high_camera_channel.height + + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, None, height, dt=fixed_now + ) + + +@pytest.mark.parametrize(("width", "height"), [(123, None), (None, 456), (123, 456)]) +async def test_snapshot_with_dimensions( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test snapshot at timestamp in URL with specified width and height.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # Replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # Assertions + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, width, height, dt=fixed_now + ) + + async def test_video_bad_event( hass: HomeAssistant, ufp: MockUFPFixture, diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5ebfd2c13ad76e..5be9cb3fe02954 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, get_fixture_path +from tests.common import MockEntityPlatform, async_mock_service, get_fixture_path CONFIG_CHILDREN_ONLY = { "name": "test", @@ -74,6 +74,7 @@ def __init__(self, hass: HomeAssistant, name: str) -> None: self._shuffle = False self._sound_mode = None self._repeat = None + self.platform = MockEntityPlatform(hass) self.service_calls = { "turn_on": async_mock_service( @@ -361,26 +362,10 @@ async def test_config_bad_key(hass: HomeAssistant) -> None: async def test_platform_setup(hass: HomeAssistant) -> None: """Test platform setup.""" config = {"name": "test", "platform": "universal"} - bad_config = {"platform": "universal"} - entities = [] - - def add_entities(new_entities): - """Add devices to list.""" - entities.extend(new_entities) - - setup_ok = True - try: - await universal.async_setup_platform( - hass, validate_config(bad_config), add_entities - ) - except MultipleInvalid: - setup_ok = False - assert not setup_ok - assert len(entities) == 0 - - await universal.async_setup_platform(hass, validate_config(config), add_entities) - assert len(entities) == 1 - assert entities[0].name == "test" + assert await async_setup_component(hass, "media_player", {"media_player": config}) + await hass.async_block_till_done() + assert hass.states.async_all() != [] + assert hass.states.get("media_player.test") is not None async def test_master_state(hass: HomeAssistant) -> None: @@ -461,11 +446,10 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: async def test_name(hass: HomeAssistant) -> None: """Test name property.""" - config = validate_config(CONFIG_CHILDREN_ONLY) - - ump = universal.UniversalMediaPlayer(hass, config) - - assert config["name"] == ump.name + assert await async_setup_component( + hass, "media_player", {"media_player": CONFIG_CHILDREN_ONLY} + ) + assert hass.states.get("media_player.test") is not None async def test_polling(hass: HomeAssistant) -> None: diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 4bee5c0e589160..300d925b82b449 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -7,7 +7,7 @@ from datetime import datetime import socket from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from urllib.parse import urlparse from async_upnp_client.aiohttp import AiohttpNotifyServer @@ -25,6 +25,15 @@ DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -36,7 +45,7 @@ TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" -TEST_DISCOVERY = ssdp.SsdpServiceInfo( +TEST_DISCOVERY = SsdpServiceInfo( ssdp_st=TEST_ST, ssdp_udn=TEST_UDN, ssdp_usn=TEST_USN, @@ -45,12 +54,12 @@ "_udn": TEST_UDN, "location": TEST_LOCATION, "usn": TEST_USN, - ssdp.ATTR_UPNP_DEVICE_TYPE: TEST_ST, - ssdp.ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, - ssdp.ATTR_UPNP_MANUFACTURER: "mock-manufacturer", - ssdp.ATTR_UPNP_MODEL_NAME: "mock-model-name", - ssdp.ATTR_UPNP_SERIAL: "mock-serial", - ssdp.ATTR_UPNP_UDN: TEST_UDN, + ATTR_UPNP_DEVICE_TYPE: TEST_ST, + ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER: "mock-manufacturer", + ATTR_UPNP_MODEL_NAME: "mock-model-name", + ATTR_UPNP_SERIAL: "mock-serial", + ATTR_UPNP_UDN: TEST_UDN, }, ssdp_headers={ "_host": TEST_HOST, @@ -75,13 +84,13 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location - mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_SERIAL] + mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ATTR_UPNP_SERIAL] mock_igd_device = create_autospec(IgdDevice) mock_igd_device.device_type = TEST_DISCOVERY.ssdp_st - mock_igd_device.name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MANUFACTURER] - mock_igd_device.model_name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + mock_igd_device.name = TEST_DISCOVERY.upnp[ATTR_UPNP_FRIENDLY_NAME] + mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ATTR_UPNP_MANUFACTURER] + mock_igd_device.model_name = TEST_DISCOVERY.upnp[ATTR_UPNP_MODEL_NAME] mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device @@ -179,7 +188,7 @@ async def ssdp_instant_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -212,7 +221,7 @@ async def ssdp_instant_discovery_multi_location(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -241,7 +250,7 @@ async def ssdp_no_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -286,11 +295,8 @@ async def mock_config_entry( # Load config_entry. entry.add_to_hass(hass) - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 8799e0faab3eca..fb650ac7a470ab 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_FORCE_POLL, CONFIG_ENTRY_HOST, @@ -21,6 +20,11 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( TEST_DISCOVERY, @@ -109,14 +113,14 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, # ssdp_udn=TEST_UDN, # Not provided. ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. + ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, + # ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), ) @@ -130,14 +134,14 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, ssdp_udn=TEST_UDN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, ssdp_all_locations=[TEST_LOCATION], upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD }, ), ) @@ -449,7 +453,7 @@ async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: """Test config flow: discovered + configured through ssdp, where the UDN differs in the SSDP-discovery vs device description.""" # Discovered via step ssdp. test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index ff74ca87b12da9..ef799a1b8afb7b 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -22,6 +22,7 @@ DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .conftest import ( TEST_DISCOVERY, @@ -125,7 +126,7 @@ async def test_async_setup_udn_mismatch( ) -> None: """Test async_setup_entry for a device which reports a different UDN from SSDP-discovery and device description.""" test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" entry = MockConfigEntry( domain=DOMAIN, @@ -146,7 +147,7 @@ async def test_async_setup_udn_mismatch( async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 67a64b265d9ffa..e9d8a9cce8f538 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from async_upnp_client.profiles.igd import IgdDevice, IgdState +import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -11,6 +12,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_upnp_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index bbd802afc959d2..8f8ed672374d8e 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,18 +1,24 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta +import logging import os -import sys +from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel import pytest from homeassistant.components import usb +from homeassistant.components.usb.utils import usb_device_from_port from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component from . import conbee_device, slae_sh_device +from tests.common import import_and_test_deprecated_constant from tests.typing import WebSocketGenerator @@ -56,10 +62,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -80,7 +82,7 @@ async def test_observer_discovery( async def _mock_monitor_observer_callback(callback): await hass.async_add_executor_job( - callback, MagicMock(action="create", device_path="/dev/new") + callback, MagicMock(action="add", device_path="/dev/new") ) def _create_mock_monitor_observer(monitor, callback, name): @@ -90,6 +92,7 @@ def _create_mock_monitor_observer(monitor, callback, name): return mock_observer with ( + patch("sys.platform", "linux"), patch("pyudev.Context"), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch("pyudev.Monitor.filter_by"), @@ -112,10 +115,65 @@ def _create_mock_monitor_observer(monitor, callback, name): assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) +async def test_polling_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +) -> None: + """Test that polling can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + mock_comports_found_device = asyncio.Event() + + def get_comports() -> list: + nonlocal mock_comports + + # Only "find" a device after a few invocations + if len(mock_comports.mock_calls) < 5: + return [] + + mock_comports_found_device.set() + return [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with ( + patch("sys.platform", "linux"), + patch( + "homeassistant.components.usb.USBDiscovery._get_monitor_observer", + return_value=None, + ), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch( + "homeassistant.components.usb.comports", side_effect=get_comports + ) as mock_comports, + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Wait until a new device is discovered after a few polling attempts + assert len(mock_config_flow.mock_calls) == 0 + await mock_comports_found_device.wait() + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + async def test_removal_by_observer_before_started( hass: HomeAssistant, operating_system ) -> None: @@ -668,10 +726,6 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_on_wsl_fallback_without_throwing_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -710,10 +764,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_not_discovered_by_observer_before_started_on_docker( hass: HomeAssistant, docker ) -> None: @@ -1160,3 +1210,192 @@ async def test_cp2102n_ordering_on_macos( # We always use `cu.SLAB_USBtoUART` assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/cu.SLAB_USBtoUART2" + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "UsbServiceInfo", + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + UsbServiceInfo, + ), + ], +) +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, + usb, + constant_name, + replacement_name, + replacement, + "2026.2", + ) + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the registration of a port event callback.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock() + mock_callback2 = Mock() + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) + cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + # Cancel the second callback + cancel2() + cancel2() + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Remove port 2 + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 2, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback2.mock_calls == [] # The second callback was unregistered + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Keep port 2 removed + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 3, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Nothing changed so no callback is called + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Unplug one and plug in the other + with patch("homeassistant.components.usb.comports", return_value=[port2]): + await ws_client.send_json({"id": 4, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback2.mock_calls == [] + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test port event callback failure handling.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) + mock_callback2 = Mock(side_effect=RuntimeError("Failure 2")) + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + usb.async_register_port_event_callback(hass, mock_callback1) + usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with ( + patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), + ): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Both were called even though they raised exceptions + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + assert caplog.text.count("Error in USB port event callback") == 2 + assert "Failure 1" in caplog.text + assert "Failure 2" in caplog.text diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 560566d7c4913c..4901e069aeea36 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -374,6 +374,7 @@ async def test_change_device_source( # Configure source entity 3 (without a device) source_config_entry_3 = MockConfigEntry() + source_config_entry_3.add_to_hass(hass) source_entity_3 = entity_registry.async_get_or_create( "sensor", "test", diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index db6cd242f3fa95..8ae054b564638a 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -33,6 +33,7 @@ from tests.common import ( MockConfigEntry, MockEntity, + MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -272,6 +273,44 @@ def send_command( assert "test" in strings +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" + + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] + + entity = _LegacyConstantsStateVacuum() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported + + async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 95f691b34f87bd..65418790280af0 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from velbusaio.channels import ( @@ -72,6 +72,7 @@ def mock_controller( 4: mock_module_no_subdevices, 99: mock_module_subdevices, } + cont.get_module.return_value = mock_module_subdevices yield controller @@ -95,7 +96,7 @@ def mock_module_subdevices() -> AsyncMock: """Mock a velbus module.""" module = AsyncMock(spec=Module) module.get_type_name.return_value = "VMB2BLE" - module.get_addresses.return_value = [99] + module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" module.get_sw_version.return_value = "2.0.0" module.is_loaded.return_value = True @@ -112,9 +113,11 @@ def mock_button() -> AsyncMock: channel.get_module_address.return_value = 1 channel.get_channel_number.return_value = 1 channel.get_module_type_name.return_value = "VMB4RYLD" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 99 + channel.get_full_name.return_value = "Bedroom kid 1" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_closed.return_value = True channel.is_on.return_value = False return channel @@ -129,9 +132,11 @@ def mock_temperature() -> AsyncMock: channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB4GPO" - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" + channel.get_module_type.return_value = 1 + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -152,12 +157,14 @@ def mock_relay() -> AsyncMock: channel = AsyncMock(spec=Relay) channel.get_categories.return_value = ["switch"] channel.get_name.return_value = "RelayName" - channel.get_module_address.return_value = 99 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 55 channel.get_module_type_name.return_value = "VMB4RYNO" - channel.get_full_name.return_value = "Full relay name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "qwerty123" + channel.get_module_type.return_value = 2 + channel.is_sub_device.return_value = True channel.is_on.return_value = True return channel @@ -168,12 +175,14 @@ def mock_select() -> AsyncMock: channel = AsyncMock(spec=SelectedProgram) channel.get_categories.return_value = ["select"] channel.get_name.return_value = "select" - channel.get_module_address.return_value = 55 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 33 channel.get_module_type_name.return_value = "VMB4RYNO" - channel.get_full_name.return_value = "Full module name" + channel.get_module_type.return_value = 3 + channel.get_full_name.return_value = "Kitchen" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" + channel.is_sub_device.return_value = False channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_selected_program.return_value = "winter" return channel @@ -185,12 +194,14 @@ def mock_buttoncounter() -> AsyncMock: channel = AsyncMock(spec=ButtonCounter) channel.get_categories.return_value = ["sensor"] channel.get_name.return_value = "ButtonCounter" - channel.get_module_address.return_value = 2 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 2 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 4 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = True channel.is_temperature.return_value = False channel.get_state.return_value = 100 @@ -209,9 +220,11 @@ def mock_sensornumber() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "m" @@ -228,9 +241,11 @@ def mock_lightsensor() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 4 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "illuminance" @@ -244,12 +259,14 @@ def mock_dimmer() -> AsyncMock: channel = AsyncMock(spec=Dimmer) channel.get_categories.return_value = ["light"] channel.get_name.return_value = "Dimmer" - channel.get_module_address.return_value = 3 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 10 channel.get_module_type_name.return_value = "VMBDN1" + channel.get_module_type.return_value = 9 channel.get_full_name.return_value = "Dimmer full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" + channel.is_sub_device.return_value = True channel.is_on.return_value = False channel.get_dimmer_state.return_value = 33 return channel @@ -261,12 +278,14 @@ def mock_cover() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverName" - channel.get_module_address.return_value = 201 - channel.get_channel_number.return_value = 2 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 9 channel.get_module_type_name.return_value = "VMB2BLE" - channel.get_full_name.return_value = "Full cover name" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "1234" + channel.is_sub_device.return_value = True channel.support_position.return_value = True channel.get_position.return_value = 50 channel.is_closed.return_value = False @@ -282,12 +301,14 @@ def mock_cover_no_position() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverNameNoPos" - channel.get_module_address.return_value = 200 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 11 channel.get_module_type_name.return_value = "VMB2BLE" - channel.get_full_name.return_value = "Full cover name no position" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "12345" + channel.is_sub_device.return_value = True channel.support_position.return_value = False channel.get_position.return_value = None channel.is_closed.return_value = False @@ -300,7 +321,7 @@ def mock_cover_no_position() -> AsyncMock: @pytest.fixture(name="config_entry") async def mock_config_entry( hass: HomeAssistant, - controller: MagicMock, + controller: AsyncMock, ) -> VelbusConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr index 998f2528d9c029..58630b9f6c996c 100644 --- a/tests/components/velbus/snapshots/test_binary_sensor.ambr +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[binary_sensor.buttonon-entry] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.buttonon', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[binary_sensor.buttonon-state] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'binary_sensor.buttonon', + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr index afe79466a44d9c..952af21b43c06a 100644 --- a/tests/components/velbus/snapshots/test_button.ambr +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[button.buttonon-entry] +# name: test_entities[button.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.buttonon', - 'has_entity_name': False, + 'entity_id': 'button.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.buttonon-state] +# name: test_entities[button.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'button.buttonon', + 'entity_id': 'button.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr index 567e45d9299dc6..b1933e51868bab 100644 --- a/tests/components/velbus/snapshots/test_climate.ambr +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[climate.temperature-entry] +# name: test_entities[climate.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,8 +24,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.temperature', - 'has_entity_name': False, + 'entity_id': 'climate.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[climate.temperature-state] +# name: test_entities[climate.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 20.0, - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'hvac_modes': list([ , , @@ -67,7 +67,7 @@ 'temperature': 21.0, }), 'context': , - 'entity_id': 'climate.temperature', + 'entity_id': 'climate.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index eb41839078d1ad..a9cbd4aae73b33 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[cover.covername-entry] +# name: test_entities[cover.basement_covername-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covername', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covername', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -28,26 +28,26 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1234-2', + 'unique_id': '1234-9', 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covername-state] +# name: test_entities[cover.basement_covername-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 50, - 'friendly_name': 'CoverName', + 'friendly_name': 'Basement CoverName', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covername', + 'entity_id': 'cover.basement_covername', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'open', }) # --- -# name: test_entities[cover.covernamenopos-entry] +# name: test_entities[cover.basement_covernamenopos-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covernamenopos', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covernamenopos', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,19 +76,19 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '12345-1', + 'unique_id': '12345-11', 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covernamenopos-state] +# name: test_entities[cover.basement_covernamenopos-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'CoverNameNoPos', + 'friendly_name': 'Basement CoverNameNoPos', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covernamenopos', + 'entity_id': 'cover.basement_covernamenopos', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index 3359cb78590ba0..406a5f2d84e32f 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -79,7 +79,7 @@ }), dict({ 'address': list([ - 99, + 88, ]), 'channels': dict({ }), diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr new file mode 100644 index 00000000000000..a55a00ef0f2af4 --- /dev/null +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_device_registry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYLD', + 'model_id': '99', + 'name': 'Bedroom kid 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-11', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '4', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4GPO', + 'model_id': '1', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'asdfghjk', + 'suggested_area': None, + 'sw_version': '3.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '8', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYNO', + 'model_id': '2', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'qwerty123', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index a4574f1b339367..b7009a0c66ab6b 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -1,12 +1,12 @@ # serializer version: 1 -# name: test_entities[light.dimmer-entry] +# name: test_entities[light.bedroom_kid_1_led_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'supported_color_modes': list([ - , + , ]), }), 'config_entry_id': , @@ -14,9 +14,9 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.dimmer', - 'has_entity_name': False, + 'entity_category': , + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,42 +27,41 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmer', + 'original_name': 'LED ButtonOn', 'platform': 'velbus', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6g7-1', + 'unique_id': 'a1b2c3d4e5f6-1', 'unit_of_measurement': None, }) # --- -# name: test_entities[light.dimmer-state] +# name: test_entities[light.bedroom_kid_1_led_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, 'color_mode': None, - 'friendly_name': 'Dimmer', + 'friendly_name': 'Bedroom kid 1 LED ButtonOn', 'supported_color_modes': list([ - , + , ]), - 'supported_features': , + 'supported_features': , }), 'context': , - 'entity_id': 'light.dimmer', + 'entity_id': 'light.bedroom_kid_1_led_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_entities[light.led_buttonon-entry] +# name: test_entities[light.dimmer_full_name_dimmer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'supported_color_modes': list([ - , + , ]), }), 'config_entry_id': , @@ -70,9 +69,9 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': , - 'entity_id': 'light.led_buttonon', - 'has_entity_name': False, + 'entity_category': None, + 'entity_id': 'light.dimmer_full_name_dimmer', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,27 +82,28 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'LED ButtonOn', + 'original_name': 'Dimmer', 'platform': 'velbus', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6-1', + 'unique_id': 'a1b2c3d4e5f6g7-10', 'unit_of_measurement': None, }) # --- -# name: test_entities[light.led_buttonon-state] +# name: test_entities[light.dimmer_full_name_dimmer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'brightness': None, 'color_mode': None, - 'friendly_name': 'LED ButtonOn', + 'friendly_name': 'Dimmer full name Dimmer', 'supported_color_modes': list([ - , + , ]), - 'supported_features': , + 'supported_features': , }), 'context': , - 'entity_id': 'light.led_buttonon', + 'entity_id': 'light.dimmer_full_name_dimmer', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index 5678c0ded5f9d1..288eb10a3c3409 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[select.select-entry] +# name: test_entities[select.kitchen_select-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18,8 +18,8 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.select', - 'has_entity_name': False, + 'entity_id': 'select.kitchen_select', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -39,10 +39,10 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[select.select-state] +# name: test_entities[select.kitchen_select-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'select', + 'friendly_name': 'Kitchen select', 'options': list([ 'none', 'summer', @@ -51,7 +51,7 @@ ]), }), 'context': , - 'entity_id': 'select.select', + 'entity_id': 'select.kitchen_select', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 132f4c7a059808..6860ad73e2c7ab 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[sensor.buttoncounter-entry] +# name: test_entities[sensor.input_buttoncounter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -34,23 +34,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_entities[sensor.buttoncounter-state] +# name: test_entities[sensor.input_buttoncounter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'ButtonCounter', + 'friendly_name': 'Input ButtonCounter', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.buttoncounter', + 'entity_id': 'sensor.input_buttoncounter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.buttoncounter_counter-entry] +# name: test_entities[sensor.input_buttoncounter_counter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -64,8 +64,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter_counter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter_counter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,24 +85,24 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_entities[sensor.buttoncounter_counter-state] +# name: test_entities[sensor.input_buttoncounter_counter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'ButtonCounter-counter', + 'friendly_name': 'Input ButtonCounter-counter', 'icon': 'mdi:counter', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.buttoncounter_counter', + 'entity_id': 'sensor.input_buttoncounter_counter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.lightsensor-entry] +# name: test_entities[sensor.input_lightsensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -116,8 +116,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.lightsensor', - 'has_entity_name': False, + 'entity_id': 'sensor.input_lightsensor', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -137,22 +137,22 @@ 'unit_of_measurement': 'illuminance', }) # --- -# name: test_entities[sensor.lightsensor-state] +# name: test_entities[sensor.input_lightsensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'LightSensor', + 'friendly_name': 'Input LightSensor', 'state_class': , 'unit_of_measurement': 'illuminance', }), 'context': , - 'entity_id': 'sensor.lightsensor', + 'entity_id': 'sensor.input_lightsensor', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '250.0', }) # --- -# name: test_entities[sensor.sensornumber-entry] +# name: test_entities[sensor.input_sensornumber-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -166,8 +166,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensornumber', - 'has_entity_name': False, + 'entity_id': 'sensor.input_sensornumber', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -187,22 +187,22 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_entities[sensor.sensornumber-state] +# name: test_entities[sensor.input_sensornumber-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SensorNumber', + 'friendly_name': 'Input SensorNumber', 'state_class': , 'unit_of_measurement': 'm', }), 'context': , - 'entity_id': 'sensor.sensornumber', + 'entity_id': 'sensor.input_sensornumber', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10.0', }) # --- -# name: test_entities[sensor.temperature-entry] +# name: test_entities[sensor.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -216,8 +216,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.temperature', - 'has_entity_name': False, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -237,16 +237,16 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.temperature-state] +# name: test_entities[sensor.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.temperature', + 'entity_id': 'sensor.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index 87f0f7eac0202d..e9090c396d19a9 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[switch.relayname-entry] +# name: test_entities[switch.living_room_relayname-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.relayname', - 'has_entity_name': False, + 'entity_id': 'switch.living_room_relayname', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[switch.relayname-state] +# name: test_entities[switch.living_room_relayname-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RelayName', + 'friendly_name': 'Living room RelayName', }), 'context': , - 'entity_id': 'switch.relayname', + 'entity_id': 'switch.living_room_relayname', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/test_button.py b/tests/components/velbus/test_button.py index d1079497879659..cf334a29b05e80 100644 --- a/tests/components/velbus/test_button.py +++ b/tests/components/velbus/test_button.py @@ -38,6 +38,9 @@ async def test_button_press( """Test button press.""" await init_integration(hass, config_entry) await hass.services.async_call( - BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: "button.buttonon"}, blocking=True + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.bedroom_kid_1_buttonon"}, + blocking=True, ) mock_button.press.assert_called_once_with() diff --git a/tests/components/velbus/test_climate.py b/tests/components/velbus/test_climate.py index fd0a268bb0f064..c843bca6e68247 100644 --- a/tests/components/velbus/test_climate.py +++ b/tests/components/velbus/test_climate.py @@ -51,7 +51,7 @@ async def test_set_target_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_TEMPERATURE: 29}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_TEMPERATURE: 29}, blocking=True, ) mock_temperature.set_temp.assert_called_once_with(29) @@ -78,7 +78,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_PRESET_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_PRESET_MODE: set_mode}, blocking=True, ) mock_temperature.set_preset.assert_called_once_with(expected_mode) @@ -102,7 +102,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: set_mode}, blocking=True, ) mock_temperature.set_mode.assert_called_once_with(set_mode) @@ -119,7 +119,7 @@ async def test_set_hvac_mode_invalid( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: "auto"}, blocking=True, ) mock_temperature.set_mode.assert_not_called() diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 5e81a3f8a36249..04b6a51043f65a 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,18 +7,18 @@ import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components import usb from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import PORT_SERIAL, PORT_TCP from tests.common import MockConfigEntry -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=PORT_SERIAL, pid="10CF", vid="0B1B", diff --git a/tests/components/velbus/test_cover.py b/tests/components/velbus/test_cover.py index fe3fbbe1594aee..24a90e0f8d1621 100644 --- a/tests/components/velbus/test_cover.py +++ b/tests/components/velbus/test_cover.py @@ -38,8 +38,8 @@ async def test_entities( @pytest.mark.parametrize( ("entity_id", "entity_num"), [ - ("cover.covername", 0), - ("cover.covernamenopos", 1), + ("cover.basement_covername", 0), + ("cover.basement_covernamenopos", 1), ], ) async def test_actions( @@ -84,7 +84,7 @@ async def test_position( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.covername", ATTR_POSITION: 25}, + {ATTR_ENTITY_ID: "cover.basement_covername", ATTR_POSITION: 25}, blocking=True, ) mock_cover.set_position.assert_called_once_with(75) diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index b7c334d7814c96..3285099f2a21e7 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -1,14 +1,18 @@ """Tests for the Velbus component initialisation.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from syrupy.assertion import SnapshotAssertion from velbusaio.exceptions import VelbusConnectionFailed +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration @@ -113,3 +117,46 @@ async def test_migrate_config_entry( await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + + +async def test_api_call( + hass: HomeAssistant, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the api call decorator action.""" + await init_integration(hass, config_entry) + + mock_relay.turn_on.side_effect = OSError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, + blocking=True, + ) + + +async def test_device_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the velbus device registry.""" + await init_integration(hass, config_entry) + + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) + assert device_parent.via_device_id is None + + device = device_registry.async_get_device(identifiers={(DOMAIN, "88-9")}) + assert device.via_device_id == device_parent.id + + device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")}) + assert device_no_sub.via_device_id is None diff --git a/tests/components/velbus/test_light.py b/tests/components/velbus/test_light.py index 344d1626bbd989..0ce93d6e6bbebb 100644 --- a/tests/components/velbus/test_light.py +++ b/tests/components/velbus/test_light.py @@ -52,7 +52,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer"}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer"}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_once_with(0, 0) @@ -60,7 +60,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_TRANSITION: 1}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_TRANSITION: 1}, blocking=True, ) mock_dimmer.restore_dimmer_state.assert_called_once_with(1) @@ -68,7 +68,11 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 1}, + { + ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 1, + }, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(0, 1) @@ -77,7 +81,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 33}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_BRIGHTNESS: 33}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(12, 0) @@ -96,7 +100,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_once_with("off") @@ -104,7 +108,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_with("on") @@ -113,7 +117,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_button.set_led_state.assert_called_with("slow") @@ -122,7 +126,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") @@ -131,7 +135,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") diff --git a/tests/components/velbus/test_select.py b/tests/components/velbus/test_select.py index 64ac2c98009f45..782ae53d440ebf 100644 --- a/tests/components/velbus/test_select.py +++ b/tests/components/velbus/test_select.py @@ -46,7 +46,7 @@ async def test_select_program( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.select", ATTR_OPTION: set_program}, + {ATTR_ENTITY_ID: "select.kitchen_select", ATTR_OPTION: set_program}, blocking=True, ) mock_select.set_selected_program.assert_called_once_with(set_program) diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py new file mode 100644 index 00000000000000..2bcbac7b80d2e6 --- /dev/null +++ b/tests/components/velbus/test_services.py @@ -0,0 +1,172 @@ +"""Velbus services tests.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.velbus.const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.issue_registry as ir + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_global_services_with_interface( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test services directed at the bus with an interface parameter.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_global_survices_with_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test services directed at the bus with a config_entry.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_set_memo_text( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: AsyncMock, +) -> None: + """Test the set_memo_text service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 1, + }, + blocking=True, + ) + config_entry.runtime_data.controller.get_module( + 1 + ).set_memo_text.assert_called_once_with("Test") + + # Test with unfound module + controller.return_value.get_module.return_value = None + with pytest.raises(ServiceValidationError, match="Module not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + + +async def test_clear_cache( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the clear_cache service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id, CONF_ADDRESS: 1}, + blocking=True, + ) + assert config_entry.runtime_data.controller.scan.call_count == 2 diff --git a/tests/components/velbus/test_switch.py b/tests/components/velbus/test_switch.py index 9efb65af68d645..ebb1da084c4f0e 100644 --- a/tests/components/velbus/test_switch.py +++ b/tests/components/velbus/test_switch.py @@ -43,7 +43,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_off.assert_called_once_with() @@ -51,7 +51,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_on.assert_called_once_with() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 512b2a007ed596..c88a21d2bbae74 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -5,6 +5,11 @@ import pytest +from homeassistant.components.velux import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +18,44 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.velux.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_velux_client() -> Generator[AsyncMock]: + """Mock a Velux client.""" + with ( + patch( + "homeassistant.components.velux.config_flow.PyVLX", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_user_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + +@pytest.fixture +def mock_discovered_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + CONF_MAC: "64:61:84:00:ab:cd", + }, + unique_id="VELUX_KLF_ABCD", + ) diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 5f7932d358ac8a..22ad10e1188020 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -2,86 +2,288 @@ from __future__ import annotations -from copy import deepcopy -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from pyvlx import PyVLXException from homeassistant.components.velux import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD 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 -DUMMY_DATA: dict[str, Any] = { - CONF_HOST: "127.0.0.1", - CONF_PASSWORD: "NotAStrongPassword", -} - -PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH = ( - "homeassistant.components.velux.config_flow.PyVLX.connect" +DHCP_DISCOVERY = DhcpServiceInfo( + ip="127.0.0.1", + hostname="VELUX_KLF_LAN_ABCD", + macaddress="64618400abcd", ) -PYVLX_CONFIG_FLOW_CLASS_PATH = "homeassistant.components.velux.config_flow.PyVLX" -error_types_to_test: list[tuple[Exception, str]] = [ - (PyVLXException("DUMMY"), "cannot_connect"), - (Exception("DUMMY"), "unknown"), -] -pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, +) -> None: + """Test starting a flow by user with valid values.""" + 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"] == {} -async def test_user_success(hass: HomeAssistant) -> None: - """Test starting a flow by user with valid values.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True) as client_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) - client_mock.return_value.disconnect.assert_called_once() - client_mock.return_value.connect.assert_called_once() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.1" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + } + assert not result["result"].unique_id - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DUMMY_DATA[CONF_HOST] - assert result["data"] == DUMMY_DATA + mock_velux_client.disconnect.assert_called_once() + mock_velux_client.connect.assert_called_once() -@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) async def test_user_errors( - hass: HomeAssistant, error: Exception, error_name: str + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test starting a flow by user but with exceptions.""" - with patch( - PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, side_effect=error - ) as connect_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) - connect_mock.assert_called_once() + mock_velux_client.connect.side_effect = exception + + 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"] == {} - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": error_name} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} -async def test_flow_duplicate_entry(hass: HomeAssistant) -> None: + mock_velux_client.connect.assert_called_once() + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate_entry( + hass: HomeAssistant, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: """Test initialized flow with a duplicate entry.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title=DUMMY_DATA[CONF_HOST], data=DUMMY_DATA - ) - - conf_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=DUMMY_DATA, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + mock_user_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + assert result["result"].unique_id == "VELUX_KLF_ABCD" + + mock_velux_client.disconnect.assert_called() + mock_velux_client.connect.assert_called() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_velux_client.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_discovered_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when already configured.""" + mock_discovered_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discover_unique_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery when already configured.""" + mock_user_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id) + + assert mock_user_config_entry.state is ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD" + + +async def test_dhcp_discovery_not_loaded( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when entry with same host not loaded.""" + mock_user_config_entry.add_to_hass(hass) + + assert mock_user_config_entry.state is not ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id is None diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e6dd11669d1c17..eb7e3eb1811f83 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -8,7 +8,6 @@ from verisure import Error as VerisureError, LoginError as VerisureLoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, @@ -18,6 +17,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD 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 @@ -333,7 +333,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="0123456789ab", hostname="mock_hostname" ), context={"source": config_entries.SOURCE_DHCP}, diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 954affb4c1aa9c..ead3ecdc173828 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -10,13 +10,17 @@ from tests.common import load_fixture, load_json_object_fixture +ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" +ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" +ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") ], "Humidifier 600S": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 5500ef1a55fb12..8272da8dfad2aa 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -7,9 +7,10 @@ import pytest from pyvesync import VeSync from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S from pyvesync.vesyncoutlet import VeSyncOutlet from pyvesync.vesyncswitch import VeSyncSwitch +import requests_mock from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -17,6 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from .common import mock_multiple_device_responses + from tests.common import MockConfigEntry @@ -100,3 +103,29 @@ def dimmable_switch_fixture(): def outlet_fixture(): """Create a mock VeSync outlet fixture.""" return Mock(VeSyncOutlet) + + +@pytest.fixture(name="humidifier") +def humidifier_fixture(): + """Create a mock VeSync humidifier fixture.""" + return Mock(VeSyncHumid200300S) + + +@pytest.fixture(name="humidifier_config_entry") +async def humidifier_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `Humidifier 200s`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "Humidifier 200s" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json new file mode 100644 index 00000000000000..a0a98bde110ddc --- /dev/null +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -0,0 +1,27 @@ +{ + "code": 0, + "result": { + "result": { + "humidity": 35, + "mist_level": 6, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 60af4ae3d5be9b..1dea5f28f2ccc5 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -464,6 +464,36 @@ # --- # name: test_fan_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 200s][entities] @@ -472,6 +502,36 @@ # --- # name: test_fan_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 2e7fe9ac1bb69a..ba6c7ab51b992e 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -335,6 +335,36 @@ # --- # name: test_light_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 200s][entities] @@ -343,6 +373,36 @@ # --- # name: test_light_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 11d931e023aab7..50bee417a2877a 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -651,20 +651,178 @@ # --- # name: test_sensor_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_200s_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': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '200s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 200s][sensor.humidifier_200s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 200s Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_200s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_600s_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': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 600S][sensor.humidifier_600s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 600S Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_600s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 4b271ee55d9bd4..596aa0c94ada65 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -229,6 +229,36 @@ # --- # name: test_switch_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 200s][entities] @@ -237,6 +267,36 @@ # --- # name: test_switch_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py new file mode 100644 index 00000000000000..3b89ba8e742ee0 --- /dev/null +++ b/tests/components/vesync/test_humidifier.py @@ -0,0 +1,227 @@ +"""Tests for the humidifier platform.""" + +from contextlib import nullcontext +from unittest.mock import patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .common import ( + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, +) + +from tests.common import MockConfigEntry + +NoException = nullcontext() + + +async def test_humidifier_state( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + expected_entities = [ + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, + ] + + assert humidifier_config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + state = hass.states.get(ENTITY_HUMIDIFIER) + + # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node + assert state.attributes.get(ATTR_HUMIDITY) == 40 + + +async def test_set_target_humidity_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_humidify method.""" + + # Setting value out of range results in ServiceValidationError and + # VeSyncHumid200300S.set_humidity does not get called. + with ( + patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 20}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_target_humidity( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of return value from VeSyncHumid200300S.set_humidity.""" + + # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 54}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_on( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_on method.""" + + # turn_on returns False indicating failure in which case humidifier.turn_on + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_off( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_off method.""" + + # turn_off returns False indicating failure in which case humidifier.turn_off + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +async def test_set_mode_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_mode method.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + ) as method_mock: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "something_invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_mode( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_mode method.""" + + # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index a089a270c948e6..3b0df1282401a9 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -5,16 +5,9 @@ import pytest from pyvesync import VeSync -from homeassistant.components.vesync import async_setup_entry -from homeassistant.components.vesync.const import ( - DOMAIN, - VS_FANS, - VS_LIGHTS, - VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry +from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -30,7 +23,9 @@ async def test_async_setup_entry__not_login( with ( patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch("homeassistant.components.vesync.async_process_devices") as process_mock, + patch( + "homeassistant.components.vesync.async_generate_device_list" + ) as process_mock, ): assert not await async_setup_entry(hass, config_entry) await hass.async_block_till_done() @@ -52,20 +47,24 @@ async def test_async_setup_entry__no_devices( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert not hass.data[DOMAIN][VS_FANS] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert not hass.data[DOMAIN][VS_SENSORS] + assert not hass.data[DOMAIN][VS_DEVICES] async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: - """Test setup connects to vesync and loads fan platform.""" + """Test setup connects to vesync and loads fan.""" fans = [fan] manager.fans = fans manager._dev_list = { @@ -78,10 +77,36 @@ async def test_async_setup_entry__loads_fans( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert hass.data[DOMAIN][VS_FANS] == [fan] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert hass.data[DOMAIN][VS_SENSORS] == [fan] + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + +async def test_async_new_device_discovery__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan as an update call.""" + + assert await hass.config_entries.async_setup(config_entry.entry_id) + # Assert platforms loaded + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert not hass.data[DOMAIN][VS_DEVICES] + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py new file mode 100644 index 00000000000000..a9230b76db0451 --- /dev/null +++ b/tests/components/vesync/test_number.py @@ -0,0 +1,66 @@ +"""Tests for the number platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .common import ENTITY_HUMIDIFIER_MIST_LEVEL + +from tests.common import MockConfigEntry + + +async def test_set_mist_level_bad_range( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level invalid value.""" + with ( + pytest.raises(ServiceValidationError), + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock, + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "10"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +async def test_set_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level usage.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "3"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of mist_level number entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_MIST_LEVEL).state == "6" diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index bd3a8eb85910f3..04d759de58456e 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry @@ -49,3 +49,11 @@ async def test_sensor_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_humidity( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of humidity sensor entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_HUMIDITY).state == "35" diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 793f3e87611592..88c3c945253adf 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.model0_boiler_temperature-entry] +# name: test_all_heating_entities[sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_boiler_temperature-state] +# name: test_all_heating_entities[sensor.model0_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -50,7 +50,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_burner_hours-entry] +# name: test_all_heating_entities[sensor.model0_burner_hours-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_burner_hours-state] +# name: test_all_heating_entities[sensor.model0_burner_hours-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner hours', @@ -100,7 +100,7 @@ 'state': '18726.3', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-entry] +# name: test_all_heating_entities[sensor.model0_burner_modulation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -135,7 +135,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-state] +# name: test_all_heating_entities[sensor.model0_burner_modulation-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner modulation', @@ -150,7 +150,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_burner_starts-entry] +# name: test_all_heating_entities[sensor.model0_burner_starts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -185,7 +185,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_burner_starts-state] +# name: test_all_heating_entities[sensor.model0_burner_starts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner starts', @@ -199,7 +199,7 @@ 'state': '14315', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -234,7 +234,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this month', @@ -248,7 +248,7 @@ 'state': '805', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this week', @@ -297,7 +297,7 @@ 'state': '84', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -332,7 +332,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this year', @@ -346,7 +346,7 @@ 'state': '8203', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,7 +381,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption today', @@ -395,7 +395,7 @@ 'state': '22', }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-entry] +# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -430,7 +430,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-state] +# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -446,7 +446,7 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-entry] +# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -481,7 +481,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-state] +# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -497,7 +497,7 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.model0_energy-entry] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -511,7 +511,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.model0_energy', + 'entity_id': 'sensor.model0_electricity_consumption_this_week', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -523,32 +523,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy', + 'original_name': 'Electricity consumption this week', 'platform': 'vicare', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'power consumption this month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', + 'translation_key': 'power_consumption_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this week', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_energy-state] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'model0 Energy', + 'friendly_name': 'model0 Electricity consumption this week', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.model0_energy', + 'entity_id': 'sensor.model0_electricity_consumption_this_week', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '7.843', + 'state': '0.829', }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-entry] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -583,7 +583,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-state] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -599,7 +599,7 @@ 'state': '207.106', }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-entry] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -634,7 +634,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-state] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -650,7 +650,58 @@ 'state': '0.219', }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-entry] +# name: test_all_heating_entities[sensor.model0_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power consumption this month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_heating_entities[sensor.model0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.843', + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -685,7 +736,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-state] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this month', @@ -699,7 +750,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-entry] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -734,7 +785,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-state] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this week', @@ -748,7 +799,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-entry] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -783,7 +834,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-state] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this year', @@ -797,7 +848,7 @@ 'state': '30946', }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-entry] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -832,7 +883,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-state] +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption today', @@ -846,7 +897,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_outside_temperature-entry] +# name: test_all_heating_entities[sensor.model0_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -881,7 +932,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_outside_temperature-state] +# name: test_all_heating_entities[sensor.model0_outside_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -897,13 +948,13 @@ 'state': '20.8', }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-entry] +# name: test_all_heating_entities[sensor.model0_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -911,7 +962,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_this_week', + 'entity_id': 'sensor.model0_supply_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -921,34 +972,34 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Electricity consumption this week', + 'original_name': 'Supply temperature', 'platform': 'vicare', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'power_consumption_this_week', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this week', - 'unit_of_measurement': , + 'translation_key': 'supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-0', + 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-state] +# name: test_all_heating_entities[sensor.model0_supply_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption this week', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'temperature', + 'friendly_name': 'model0 Supply temperature', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_this_week', + 'entity_id': 'sensor.model0_supply_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.829', + 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-entry] +# name: test_all_heating_entities[sensor.model0_supply_temperature_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -962,7 +1013,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.model0_supply_temperature', + 'entity_id': 'sensor.model0_supply_temperature_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -979,11 +1030,11 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-1', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-state] +# name: test_all_heating_entities[sensor.model0_supply_temperature_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -992,20 +1043,26 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.model0_supply_temperature', + 'entity_id': 'sensor.model0_supply_temperature_2', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '63', + 'state': '25.5', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-entry] +# name: test_all_ventilation_entities[sensor.model0_ventilation_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), }), 'config_entry_id': , 'device_class': None, @@ -1013,7 +1070,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.model0_supply_temperature_2', + 'entity_id': 'sensor.model0_ventilation_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1023,31 +1080,100 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Supply temperature', + 'original_name': 'Ventilation level', 'platform': 'vicare', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'supply_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-1', - 'unit_of_measurement': , + 'translation_key': 'ventilation_level', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_level', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-state] +# name: test_all_ventilation_entities[sensor.model0_ventilation_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 Supply temperature', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation level', + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), }), 'context': , - 'entity_id': 'sensor.model0_supply_temperature_2', + 'entity_id': 'sensor.model0_ventilation_level', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.5', + 'state': 'levelone', + }) +# --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation reason', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_reason', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation reason', + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'permanent', }) # --- # name: test_room_sensors[sensor.model0_humidity-entry] diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index d44fd1b9fed96b..ce3b3c27f06f1b 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -9,12 +9,12 @@ ) from syrupy.assertion import SnapshotAssertion -from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import MOCK_MAC, MODULE @@ -28,7 +28,7 @@ CONF_CLIENT_ID: "5678", } -DHCP_INFO = dhcp.DhcpServiceInfo( +DHCP_INFO = DhcpServiceInfo( ip="1.1.1.1", hostname="mock_hostname", macaddress=MOCK_MAC.lower().replace(":", ""), diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index afd3232478ae4b..9b8b69f29db0dd 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -16,7 +16,7 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_entities( +async def test_all_heating_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, @@ -35,6 +35,24 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_ventilation_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + with ( + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_room_sensors( hass: HomeAssistant, diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py new file mode 100644 index 00000000000000..13ca77f2792425 --- /dev/null +++ b/tests/components/vicare/test_utils.py @@ -0,0 +1,23 @@ +"""Test ViCare utils.""" + +import pytest + +from homeassistant.components.vicare.utils import filter_state + + +@pytest.mark.parametrize( + ("state", "expected_result"), + [ + (None, None), + ("unknown", None), + ("nothing", None), + ("levelOne", "levelOne"), + ], +) +async def test_filter_state( + state: str | None, + expected_result: str | None, +) -> None: + """Test filter_state.""" + + assert filter_state(state) == expected_result diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 51151ae8f429f3..5fbf61a58da65a 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,7 +2,6 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, MediaPlayerDeviceClass, @@ -27,6 +26,7 @@ CONF_NAME, CONF_PIN, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import slugify NAME = "Vizio" @@ -173,7 +173,7 @@ def __init__(self, auth_token: str) -> None: ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST, ZEROCONF_PORT = HOST.split(":", maxsplit=2) -MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_SERVICE_INFO = ZeroconfServiceInfo( ip_address=ip_address(ZEROCONF_HOST), ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py index 68f11a27b95937..6119d94c06c1c0 100644 --- a/tests/components/vodafone_station/__init__.py +++ b/tests/components/vodafone_station/__init__.py @@ -1 +1,13 @@ """Tests for the Vodafone Station 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/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index c36382e4c0188f..7763db5044ad5a 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -2,11 +2,31 @@ from datetime import UTC, datetime +from aiovodafone import VodafoneStationDevice import pytest -from .const import DEVICE_DATA_QUERY, SENSOR_DATA_QUERY +from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from tests.common import AsyncMock, Generator, patch +from .const import DEVICE_1_MAC + +from tests.common import ( + AsyncMock, + Generator, + MockConfigEntry, + load_json_object_fixture, + patch, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.vodafone_station.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry @pytest.fixture @@ -17,12 +37,41 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", autospec=True, ) as mock_router, + patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + new=mock_router, + ), ): router = mock_router.return_value - router.get_devices_data.return_value = DEVICE_DATA_QUERY - router.get_sensor_data.return_value = SENSOR_DATA_QUERY + router.get_devices_data.return_value = { + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name="WifiDevice0", + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + } + router.get_sensor_data.return_value = load_json_object_fixture( + "get_sensor_data.json", DOMAIN + ) router.convert_uptime.return_value = datetime( 2024, 11, 19, 20, 19, 0, tzinfo=UTC ) router.base_url = "https://fake_host" yield router + + +@pytest.fixture +def mock_config_entry() -> Generator[MockConfigEntry]: + """Mock a Vodafone Station config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index fc6bbd013982c1..0f1ed2ba7daa0c 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -1,117 +1,3 @@ """Common stuff for Vodafone Station tests.""" -from aiovodafone.api import VodafoneStationDevice - -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME - -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - } - ] - } -} - -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] - - -DEVICE_DATA_QUERY = { - "xx:xx:xx:xx:xx:xx": VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name="WifiDevice0", - mac="xx:xx:xx:xx:xx:xx", - type="laptop", - wifi="2.4G", - ), -} - -SERIAL = "m123456789" - -SENSOR_DATA_QUERY = { - "sys_serial_number": SERIAL, - "sys_firmware_version": "XF6_4.0.05.04", - "sys_bootloader_version": "0220", - "sys_hardware_version": "RHG3006 v1", - "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", - "sys_uptime": "12:16:41", - "sys_cpu_usage": "97%", - "sys_reboot_cause": "Web Reboot", - "sys_memory_usage": "51.94%", - "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", - "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", - "vf_internet_key_online_since": "", - "vf_internet_key_ip_addr": "0.0.0.0", - "vf_internet_key_system": "0.0.0.0", - "vf_internet_key_mode": "Auto", - "sys_voip_version": "v02.01.00_01.13a\n", - "sys_date_time": "20.10.2024 | 03:44 pm", - "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", - "sys_model_name": "RHG3006", - "inter_ip_address": "1.1.1.1", - "inter_gateway": "1.1.1.2", - "inter_primary_dns": "1.1.1.3", - "inter_secondary_dns": "1.1.1.4", - "inter_firewall": "601036", - "inter_wan_ip_address": "1.1.1.1", - "inter_ipv6_link_local_address": "", - "inter_ipv6_link_global_address": "", - "inter_ipv6_gateway": "", - "inter_ipv6_prefix_delegation": "", - "inter_ipv6_dns_address1": "", - "inter_ipv6_dns_address2": "", - "lan_ip_network": "192.168.0.1/24", - "lan_default_gateway": "192.168.0.1", - "lan_subnet_address_subnet1": "", - "lan_mac_address": "11:22:33:44:55:66", - "lan_dhcp_server": "601036", - "lan_dhcpv6_server": "601036", - "lan_router_advertisement": "601036", - "lan_ipv6_default_gateway": "fe80::1", - "lan_port1_switch_mode": "1301722", - "lan_port2_switch_mode": "1301722", - "lan_port3_switch_mode": "1301722", - "lan_port4_switch_mode": "1301722", - "lan_port1_switch_speed": "10", - "lan_port2_switch_speed": "100", - "lan_port3_switch_speed": "1000", - "lan_port4_switch_speed": "1000", - "lan_port1_switch_status": "1301724", - "lan_port2_switch_status": "1301724", - "lan_port3_switch_status": "1301724", - "lan_port4_switch_status": "1301724", - "wifi_status": "601036", - "wifi_name": "Wifi-Main-Network", - "wifi_mac_address": "AA:BB:CC:DD:EE:FF", - "wifi_security": "401027", - "wifi_channel": "8", - "wifi_bandwidth": "573", - "guest_wifi_status": "601037", - "guest_wifi_name": "Wifi-Guest", - "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", - "guest_wifi_security": "401027", - "guest_wifi_channel": "N/A", - "guest_wifi_ip": "192.168.2.1", - "guest_wifi_subnet_addr": "255.255.255.0", - "guest_wifi_dhcp_server": "192.168.2.1", - "wifi_status_5g": "601036", - "wifi_name_5g": "Wifi-Main-Network", - "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", - "wifi_security_5g": "401027", - "wifi_channel_5g": "36", - "wifi_bandwidth_5g": "4803", - "guest_wifi_status_5g": "601037", - "guest_wifi_name_5g": "Wifi-Guest", - "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", - "guest_wifi_channel_5g": "N/A", - "guest_wifi_security_5g": "401027", - "guest_wifi_ip_5g": "192.168.2.1", - "guest_wifi_subnet_addr_5g": "255.255.255.0", - "guest_wifi_dhcp_server_5g": "192.168.2.1", -} +DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" diff --git a/tests/components/vodafone_station/fixtures/get_sensor_data.json b/tests/components/vodafone_station/fixtures/get_sensor_data.json new file mode 100644 index 00000000000000..6a6229ebd1883d --- /dev/null +++ b/tests/components/vodafone_station/fixtures/get_sensor_data.json @@ -0,0 +1,81 @@ +{ + "sys_serial_number": "m123456789", + "sys_firmware_version": "XF6_4.0.05.04", + "sys_bootloader_version": "0220", + "sys_hardware_version": "RHG3006 v1", + "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", + "sys_uptime": "12:16:41", + "sys_cpu_usage": "97%", + "sys_reboot_cause": "Web Reboot", + "sys_memory_usage": "51.94%", + "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", + "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", + "vf_internet_key_online_since": "", + "vf_internet_key_ip_addr": "0.0.0.0", + "vf_internet_key_system": "0.0.0.0", + "vf_internet_key_mode": "Auto", + "sys_voip_version": "v02.01.00_01.13a\n", + "sys_date_time": "20.10.2024 | 03:44 pm", + "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", + "sys_model_name": "RHG3006", + "inter_ip_address": "1.1.1.1", + "inter_gateway": "1.1.1.2", + "inter_primary_dns": "1.1.1.3", + "inter_secondary_dns": "1.1.1.4", + "inter_firewall": "601036", + "inter_wan_ip_address": "1.1.1.1", + "inter_ipv6_link_local_address": "", + "inter_ipv6_link_global_address": "", + "inter_ipv6_gateway": "", + "inter_ipv6_prefix_delegation": "", + "inter_ipv6_dns_address1": "", + "inter_ipv6_dns_address2": "", + "lan_ip_network": "192.168.0.1/24", + "lan_default_gateway": "192.168.0.1", + "lan_subnet_address_subnet1": "", + "lan_mac_address": "11:22:33:44:55:66", + "lan_dhcp_server": "601036", + "lan_dhcpv6_server": "601036", + "lan_router_advertisement": "601036", + "lan_ipv6_default_gateway": "fe80::1", + "lan_port1_switch_mode": "1301722", + "lan_port2_switch_mode": "1301722", + "lan_port3_switch_mode": "1301722", + "lan_port4_switch_mode": "1301722", + "lan_port1_switch_speed": "10", + "lan_port2_switch_speed": "100", + "lan_port3_switch_speed": "1000", + "lan_port4_switch_speed": "1000", + "lan_port1_switch_status": "1301724", + "lan_port2_switch_status": "1301724", + "lan_port3_switch_status": "1301724", + "lan_port4_switch_status": "1301724", + "wifi_status": "601036", + "wifi_name": "Wifi-Main-Network", + "wifi_mac_address": "AA:BB:CC:DD:EE:FF", + "wifi_security": "401027", + "wifi_channel": "8", + "wifi_bandwidth": "573", + "guest_wifi_status": "601037", + "guest_wifi_name": "Wifi-Guest", + "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", + "guest_wifi_security": "401027", + "guest_wifi_channel": "N/A", + "guest_wifi_ip": "192.168.2.1", + "guest_wifi_subnet_addr": "255.255.255.0", + "guest_wifi_dhcp_server": "192.168.2.1", + "wifi_status_5g": "601036", + "wifi_name_5g": "Wifi-Main-Network", + "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", + "wifi_security_5g": "401027", + "wifi_channel_5g": "36", + "wifi_bandwidth_5g": "4803", + "guest_wifi_status_5g": "601037", + "guest_wifi_name_5g": "Wifi-Guest", + "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", + "guest_wifi_channel_5g": "N/A", + "guest_wifi_security_5g": "401027", + "guest_wifi_ip_5g": "192.168.2.1", + "guest_wifi_subnet_addr_5g": "255.255.255.0", + "guest_wifi_dhcp_server_5g": "192.168.2.1" +} diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr new file mode 100644 index 00000000000000..dc7953ac42a095 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[button.vodafone_station_m123456789_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'm123456789_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.vodafone_station_m123456789_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Vodafone Station (m123456789) Restart', + }), + 'context': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000000..834c8b14459782 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + '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': None, + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'xx:xx:xx:xx:xx:xx', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'host_name': 'WifiDevice0', + 'ip': '192.168.1.10', + 'mac': 'xx:xx:xx:xx:xx:xx', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..eb1676938b58ec --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active connection', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_connection', + 'unique_id': 'm123456789_inter_ip_address', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vodafone Station (m123456789) Active connection', + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + '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': 'CPU usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_cpu_usage', + 'unique_id': 'm123456789_sys_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) CPU usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + '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': 'Memory usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_memory_usage', + 'unique_id': 'm123456789_sys_memory_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Memory usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.94', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + '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': 'Reboot cause', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_reboot_cause', + 'unique_id': 'm123456789_sys_reboot_cause', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Reboot cause', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Web Reboot', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_uptime', + 'unique_id': 'm123456789_sys_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vodafone Station (m123456789) Uptime', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-19T20:19:00+00:00', + }) +# --- diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index 8b9b0753caa697..d5f377d3f6fe2b 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -1,56 +1,48 @@ """Tests for Vodafone Station button platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers import entity_registry as er + +from . import setup_integration -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY, SERIAL +from tests.common import MockConfigEntry, snapshot_platform -from tests.common import MockConfigEntry +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.BUTTON] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_button(hass: HomeAssistant, entity_registry: EntityRegistry) -> None: + +async def test_pressing_button( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test device restart button.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.restart_router", - ) as mock_router_restart, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_id = f"button.vodafone_station_{SERIAL}_restart" - - # restart button - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_UNKNOWN - - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == f"{SERIAL}_reboot" - - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_router_restart.call_count == 1 + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + mock_vodafone_station_router.restart_router.assert_called_once() diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3a54f250871666..68f8247bdf9324 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -1,8 +1,13 @@ """Tests for Vodafone Station config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + ModelNotSupported, +) import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME @@ -12,39 +17,36 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA - from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_user( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: """Test starting a flow by user.""" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_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" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_USERNAME] == "fake_username" - assert result["data"][CONF_PASSWORD] == "fake_password" - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + assert not result["result"].unique_id assert mock_setup_entry.called @@ -52,14 +54,20 @@ async def test_user(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), - (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -68,178 +76,153 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with patch( - "aiovodafone.api.VodafoneStationSercommApi.login", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error - - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "fake_host" - assert result2["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", - } - - -async def test_reauth_successful(hass: HomeAssistant) -> None: - """Test starting a reauthentication flow.""" + mock_vodafone_station_router.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vodafone_station_router.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "fake_host" + assert result["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user with a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) + mock_vodafone_station_router.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - result = await mock_config.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - side_effect=side_effect, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error - - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test options flow.""" + mock_vodafone_station_router.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" - result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() + +async def test_options_flow( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py new file mode 100644 index 00000000000000..5133d0da9804a7 --- /dev/null +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -0,0 +1,64 @@ +"""Define tests for the Vodafone Station device tracker.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.vodafone_station.const import SCAN_INTERVAL +from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import DEVICE_1_MAC + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_consider_home( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if device is considered not_home when disconnected.""" + await setup_integration(hass, mock_config_entry) + + device_tracker = "device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx" + + state = hass.states.get(device_tracker) + assert state + assert state.state == STATE_HOME + + mock_vodafone_station_router.get_devices_data.return_value[ + DEVICE_1_MAC + ].connected = False + + freezer.tick(SCAN_INTERVAL + CONSIDER_HOME_SECONDS) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(device_tracker) + assert state + assert state.state == STATE_NOT_HOME diff --git a/tests/components/vodafone_station/test_diagnostics.py b/tests/components/vodafone_station/test_diagnostics.py index 02918d8191226c..5a4a46ce6935a4 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -2,16 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -20,29 +18,17 @@ async def test_entry_diagnostics( hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + await setup_integration(hass, mock_config_entry) + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index 3a63566b5dca25..ddf97824c753f3 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -1,21 +1,37 @@ """Tests for Vodafone Station sensor platform.""" -from copy import deepcopy -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiovodafone import CannotAuthenticate +from aiovodafone.exceptions import AlreadyLogged, CannotConnect from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.vodafone_station.const import ( - DOMAIN, - LINE_TYPES, - SCAN_INTERVAL, -) +from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + -from .const import MOCK_USER_DATA, SENSOR_DATA_QUERY +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) -from tests.common import MockConfigEntry, async_fire_time_changed + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -30,26 +46,22 @@ async def test_active_connection_type( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, - connection_type, - index, + mock_config_entry: MockConfigEntry, + connection_type: str, + index: int, ) -> None: """Test device connection type.""" + await setup_integration(hass, mock_config_entry) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - active_connection_entity = f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_active_connection" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" state = hass.states.get(active_connection_entity) assert state - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN - sensor_data = deepcopy(SENSOR_DATA_QUERY) - sensor_data[connection_type] = "1.1.1.1" - mock_vodafone_station_router.get_sensor_data.return_value = sensor_data + mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( + "1.1.1.1" + ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -65,19 +77,13 @@ async def test_uptime( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test device uptime shift.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) uptime = "2024-11-19T20:19:00+00:00" - uptime_entity = ( - f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_uptime" - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + uptime_entity = "sensor.vodafone_station_m123456789_uptime" state = hass.states.get(uptime_entity) assert state @@ -92,3 +98,32 @@ async def test_uptime( state = hass.states.get(uptime_entity) assert state assert state.state == uptime + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotAuthenticate, + AlreadyLogged, + ConnectionResetError, + ], +) +async def test_coordinator_client_connector_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test ClientConnectorError on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.get_devices_data.side_effect = side_effect + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.vodafone_station_m123456789_uptime") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 44ac8e4d77f122..55d8ac4473c470 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -22,18 +22,18 @@ async def test_call_in_progress( voip_device: VoIPDevice, ) -> None: """Test call in progress.""" - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state is not None assert state.state == "off" voip_device.set_is_active(True) - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state.state == "on" voip_device.set_is_active(False) - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state.state == "off" @@ -45,9 +45,9 @@ async def test_assist_in_progress_disabled_by_default( ) -> None: """Test assist in progress binary sensor is added disabled.""" - assert not hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + assert not hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") entity_entry = entity_registry.async_get( - "binary_sensor.192_168_1_210_call_in_progress" + "binary_sensor.sip_192_168_1_210_5060_call_in_progress" ) assert entity_entry assert entity_entry.disabled @@ -63,7 +63,7 @@ async def test_assist_in_progress_issue( ) -> None: """Test assist in progress binary sensor.""" - call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None @@ -96,7 +96,7 @@ async def test_assist_in_progress_repair_flow( ) -> None: """Test assist in progress binary sensor deprecation issue flow.""" - call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 55359b8407de2f..d16ac76d290de1 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN @@ -9,6 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from tests.common import MockConfigEntry + async def test_device_registry_info( hass: HomeAssistant, @@ -21,10 +24,10 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device is not None - assert device.name == call_info.caller_ip + assert device.name == call_info.caller_endpoint.uri assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" @@ -36,7 +39,7 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.sw_version == "2.0.0.0" @@ -53,7 +56,7 @@ async def test_device_registry_info_from_unknown_phone( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.manufacturer is None assert device.model == "Unknown" @@ -68,11 +71,47 @@ async def test_remove_device_registry_entry( ) -> None: """Test removing a device registry entry.""" assert voip_device.voip_id in voip_devices.devices - assert hass.states.get("switch.192_168_1_210_allow_calls") is not None + assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is not None device_registry.async_remove_device(voip_device.device_id) await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("switch.192_168_1_210_allow_calls") is None + assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is None assert voip_device.voip_id not in voip_devices.devices + + +@pytest.fixture +async def legacy_dev_reg_entry( + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + call_info: CallInfo, +) -> None: + """Fixture to run before we set up the VoIP integration via fixture.""" + return device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, call_info.caller_ip)}, + ) + + +async def test_device_registry_migation( + hass: HomeAssistant, + legacy_dev_reg_entry: dr.DeviceEntry, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry migrates old devices.""" + voip_device = voip_devices.async_get_or_create(call_info) + assert voip_device.voip_id == call_info.caller_endpoint.uri + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device is not None + assert device.id == legacy_dev_reg_entry.id + assert device.identifiers == {(DOMAIN, call_info.caller_endpoint.uri)} + assert device.name == call_info.caller_endpoint.uri + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index 78bb8d6c6b4805..1b45c73953583c 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -15,7 +15,7 @@ async def test_pipeline_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_assistant") + state = hass.states.get("select.sip_192_168_1_210_5060_assistant") assert state is not None assert state.state == "preferred" @@ -30,6 +30,6 @@ async def test_vad_sensitivity_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_finished_speaking_detection") + state = hass.states.get("select.sip_192_168_1_210_5060_finished_speaking_detection") assert state is not None assert state.state == "default" diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py index 8b3cd03f2acae3..ac331ed01a7b74 100644 --- a/tests/components/voip/test_switch.py +++ b/tests/components/voip/test_switch.py @@ -13,41 +13,41 @@ async def test_allow_call( """Test allow call.""" assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state is not None assert state.state == "off" await hass.config_entries.async_reload(config_entry.entry_id) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "off" await hass.services.async_call( "switch", "turn_on", - {"entity_id": "switch.192_168_1_210_allow_calls"}, + {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, blocking=True, ) assert voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "on" await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "on" await hass.services.async_call( "switch", "turn_off", - {"entity_id": "switch.192_168_1_210_allow_calls"}, + {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, blocking=True, ) assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "off" diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 9c3708f970ce24..85e9e250ab9a25 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -4,11 +4,11 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.volumio.config_flow import CannotConnectError from homeassistant.components.volumio.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ } -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index 479a879a583592..a58c7c0eab8d6a 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:58+00:00', }) # --- # name: test_sensor[sensor.sonic_power_supply_mode-entry] @@ -501,6 +501,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:57+00:00', }) # --- diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 58632c7548bc72..78e375857ed69b 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -140,11 +140,11 @@ async def test_power_supply_webhook( power_supply_change_data = { "type": "power-supply-changed", - "data": {"supply": "external"}, + "data": {"supply": "external_battery"}, } client = await hass_client_no_auth() await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "external" + assert hass.states.get(entity_id).state == "battery_external" diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py index d6c096f9d3a439..5027b235eb1c73 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -3,7 +3,6 @@ from homeassistant.components.webostv.const import DOMAIN from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .const import CLIENT_KEY, FAKE_UUID, HOST, TV_NAME @@ -25,11 +24,7 @@ async def setup_webostv( ) entry.add_to_hass(hass) - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: HOST}}, - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index a30ae933cca0eb..1e3f7ecdc674ff 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -7,7 +7,15 @@ from homeassistant.components.webostv.const import LIVE_TV_APP_ID -from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS +from .const import ( + CHANNEL_1, + CHANNEL_2, + CLIENT_KEY, + FAKE_UUID, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, +) @pytest.fixture @@ -28,7 +36,7 @@ def client_fixture(): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": "TVFAKE"} + client.system_info = {"modelName": TV_MODEL} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py index afaed224e83ba4..52453d4ffa9635 100644 --- a/tests/components/webostv/const.py +++ b/tests/components/webostv/const.py @@ -2,10 +2,12 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.util import slugify FAKE_UUID = "some-fake-uuid" -TV_NAME = "fake_webos" -ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +TV_MODEL = "MODEL" +TV_NAME = f"LG webOS TV {TV_MODEL}" +ENTITY_ID = f"{MP_DOMAIN}.{slugify(TV_NAME)}" HOST = "1.2.3.4" CLIENT_KEY = "some-secret" diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..a9bd6e91ee053b --- /dev/null +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'client': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'hello_info': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_connected': True, + 'is_on': True, + 'is_registered': True, + 'software_info': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'sound_output': 'speaker', + 'system_info': dict({ + 'modelName': 'MODEL', + }), + }), + 'entry': dict({ + 'data': dict({ + 'client_secret': '**REDACTED**', + 'host': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'webostv', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'LG webOS TV MODEL', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..78c0bd517a617f --- /dev/null +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_entity_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV MODEL', + 'is_volume_muted': False, + 'media_content_type': , + 'media_title': 'Channel 1', + 'sound_output': 'speaker', + 'source': 'Live TV', + 'source_list': list([ + 'Input01', + 'Input02', + 'Live TV', + ]), + 'supported_features': , + 'volume_level': 0.37, + }), + 'context': , + 'entity_id': 'media_player.lg_webos_tv_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_attributes.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'webostv', + 'some-fake-uuid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'LG', + 'model': 'MODEL', + 'model_id': None, + 'name': 'LG webOS TV MODEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'major.minor', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index cc335a4fb41a0f..38c78bd087a007 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,63 +1,48 @@ """Test the WebOS Tv config flow.""" -import dataclasses -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID from homeassistant.config_entries import SOURCE_SSDP -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import setup_webostv -from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +from .const import ( + CLIENT_KEY, + FAKE_UUID, + HOST, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, + TV_NAME, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_USER_CONFIG = { - CONF_HOST: HOST, - CONF_NAME: TV_NAME, -} +MOCK_USER_CONFIG = {CONF_HOST: HOST} -MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", - ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", + ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", + ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", }, ) async def test_form(hass: HomeAssistant, client) -> None: - """Test we get the form.""" - assert client - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - data=MOCK_USER_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing" - + """Test successful user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -72,10 +57,10 @@ async def test_form(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID @pytest.mark.parametrize( @@ -109,27 +94,44 @@ async def test_options_flow_live_tv_in_apps( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None: """Test options config flow cannot retrieve sources.""" entry = await setup_webostv(hass) - client.connect = Mock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = ConnectionRefusedError result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_retrieve"} + # recover + client.connect.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SOURCES: ["Input01", "Input02"]}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_SOURCES] == ["Input01", "Input02"] + async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: """Test we handle cannot connect error.""" @@ -139,14 +141,22 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=ConnectionRefusedError()) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = ConnectionRefusedError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_form_pairexception(hass: HomeAssistant, client) -> None: @@ -157,20 +167,27 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=WebOsTvPairError("error")) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = WebOsTvPairError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "error_pairing" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "error_pairing"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_entry_already_configured(hass: HomeAssistant, client) -> None: """Test entry already configured.""" await setup_webostv(hass) - assert client result = await hass.config_entries.flow.async_init( DOMAIN, @@ -184,8 +201,6 @@ async def test_entry_already_configured(hass: HomeAssistant, client) -> None: async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) @@ -194,11 +209,18 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: """Test abort if ssdp paring is already in progress.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -209,39 +231,20 @@ async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" - result2 = await hass.config_entries.flow.async_init( + # Start another ssdp flow to make sure it aborts as already in progress + result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" - - -async def test_ssdp_not_update_uuid(hass: HomeAssistant, client) -> None: - """Test that ssdp not updates different host.""" - entry = await setup_webostv(hass, None) - assert client - assert entry.unique_id is None - - discovery_info = dataclasses.replace(MOCK_DISCOVERY_INFO) - discovery_info.ssdp_location = "http://1.2.3.5" - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pairing" - assert entry.unique_id is None + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: """Test abort if uuid is already configured, verify host update.""" - entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:]) - assert client - assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] + entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:]) + assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:] assert entry.data[CONF_HOST] == HOST result = await hass.config_entries.flow.async_init( @@ -253,11 +256,9 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - user_config = { - CONF_HOST: "new_host", - CONF_NAME: TV_NAME, - } + user_config = {CONF_HOST: "new_host"} + # Start another flow to make sure it aborts and updates host result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -272,19 +273,14 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "new_host" -async def test_reauth_successful( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_successful(hass: HomeAssistant, client) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -295,7 +291,7 @@ async def test_reauth_successful( assert result["step_id"] == "reauth_confirm" assert entry.data[CONF_CLIENT_SECRET] == CLIENT_KEY - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -306,18 +302,15 @@ async def test_reauth_successful( @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "reauth_unsuccessful"), + (ConnectionRefusedError, "cannot_connect"), ], ) -async def test_reauth_errors( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch, side_effect, reason -) -> None: +async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -327,10 +320,93 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.connect.side_effect = side_effect() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: + """Test that the reconfigure is successful.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "new_host" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test reconfigure errors.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: + """Test abort if reconfigure host is wrong webOS TV device.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.hello_info = {"deviceUUID": "wrong_uuid"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 41045969335bae..284cd8ad10851f 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -104,10 +104,10 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -async def test_failure_scenarios( +async def test_invalid_trigger_raises( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client ) -> None: - """Test failure scenarios.""" + """Test invalid trigger platform or device id raises.""" await setup_webostv(hass) # Test wrong trigger platform type @@ -128,7 +128,27 @@ async def test_failure_scenarios( }, ) - entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + +@pytest.mark.parametrize( + ("domain", "entry_state"), + [ + (DOMAIN, ConfigEntryState.NOT_LOADED), + ("fake", ConfigEntryState.LOADED), + ], +) +async def test_invalid_entry_raises( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + caplog: pytest.LogCaptureFixture, + domain: str, + entry_state: ConfigEntryState, +) -> None: + """Test device id not loaded or from another domain raises.""" + await setup_webostv(hass) + + entry = MockConfigEntry(domain=domain, state=entry_state, data={}) + entry.runtime_data = None entry.add_to_hass(hass) device = device_registry.async_get_or_create( diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021ad0..d35dd1fb883e5e 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by LG webOS Smart TV.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + from homeassistant.core import HomeAssistant from . import setup_webostv @@ -10,56 +12,13 @@ async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, client + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + client, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" entry = await setup_webostv(hass) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { - "client": { - "is_registered": True, - "is_connected": True, - "current_app_id": "com.webos.app.livetv", - "current_channel": { - "channelId": "ch1id", - "channelName": "Channel 1", - "channelNumber": "1", - }, - "apps": { - "com.webos.app.livetv": { - "icon": REDACTED, - "id": "com.webos.app.livetv", - "largeIcon": REDACTED, - "title": "Live TV", - } - }, - "inputs": { - "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, - "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, - }, - "system_info": {"modelName": "TVFAKE"}, - "software_info": {"major_ver": "major", "minor_ver": "minor"}, - "hello_info": {"deviceUUID": "**REDACTED**"}, - "sound_output": "speaker", - "is_on": True, - }, - "entry": { - "entry_id": entry.entry_id, - "version": 1, - "minor_version": 1, - "domain": "webostv", - "title": "fake_webos", - "data": { - "client_secret": "**REDACTED**", - "host": "**REDACTED**", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - "created_at": entry.created_at.isoformat(), - "modified_at": entry.modified_at.isoformat(), - "discovery_keys": {}, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index e2638c86f5e0d7..cd8f443c8fdb74 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -1,24 +1,21 @@ """The tests for the LG webOS TV platform.""" -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError -import pytest -from homeassistant.components.webostv.const import DOMAIN +from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_SECRET, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import setup_webostv +from .const import ENTITY_ID -async def test_reauth_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_setup_entry(hass: HomeAssistant, client) -> None: """Test reauth flow triggered by setup entry.""" - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -35,12 +32,46 @@ async def test_reauth_setup_entry( assert flow["context"].get("entry_id") == entry.entry_id -async def test_key_update_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_key_update_setup_entry(hass: HomeAssistant, client) -> None: """Test key update from setup entry.""" - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.LOADED assert entry.data[CONF_CLIENT_SECRET] == "new_key" + + +async def test_update_options(hass: HomeAssistant, client) -> None: + """Test update options triggers reload.""" + config_entry = await setup_webostv(hass) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.update_listeners is not None + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input01", "Input02", "Live TV"] + + # remove Input01 and reload + new_options = config_entry.options.copy() + new_options[CONF_SOURCES] = ["Input02", "Live TV"] + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input02", "Live TV"] + + +async def test_disconnect_on_stop(hass: HomeAssistant, client) -> None: + """Test we disconnect the client and clear callbacks when Home Assistants stops.""" + config_entry = await setup_webostv(hass) + + assert client.disconnect.call_count == 0 + assert client.clear_state_update_callbacks.call_count == 0 + assert config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert client.disconnect.call_count == 1 + assert client.clear_state_update_callbacks.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index e4c02e680bd9e9..7dea412f4fab90 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -2,10 +2,12 @@ from datetime import timedelta from http import HTTPStatus -from unittest.mock import Mock from aiowebostv import WebOsTvPairError +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components import automation from homeassistant.components.media_player import ( @@ -19,7 +21,6 @@ DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaPlayerState, MediaType, @@ -42,9 +43,9 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_COMMAND, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_CLIENT_SECRET, ENTITY_MATCH_NONE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -58,13 +59,11 @@ SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, - STATE_ON, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -144,7 +143,7 @@ async def test_media_play_pause(hass: HomeAssistant, client) -> None: ], ) async def test_media_next_previous_track( - hass: HomeAssistant, client, service, client_call, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, service, client_call ) -> None: """Test media next/previous track services.""" await setup_webostv(hass) @@ -157,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - monkeypatch.setattr(client, "current_app_id", "in1") + client.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -270,14 +269,11 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: async def test_device_info_startup_off( - hass: HomeAssistant, - client, - monkeypatch: pytest.MonkeyPatch, - device_registry: dr.DeviceRegistry, + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - monkeypatch.setattr(client, "system_info", None) - monkeypatch.setattr(client, "is_on", False) + client.system_info = None + client.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -296,8 +292,8 @@ async def test_device_info_startup_off( async def test_entity_attributes( hass: HomeAssistant, client, - monkeypatch: pytest.MonkeyPatch, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) @@ -305,28 +301,17 @@ async def test_entity_attributes( # Attributes when device is on state = hass.states.get(ENTITY_ID) - attrs = state.attributes - - assert state.state == STATE_ON - assert state.name == TV_NAME - assert attrs[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert attrs[ATTR_MEDIA_VOLUME_MUTED] is False - assert attrs[ATTR_MEDIA_VOLUME_LEVEL] == 0.37 - assert attrs[ATTR_INPUT_SOURCE] == "Live TV" - assert attrs[ATTR_INPUT_SOURCE_LIST] == ["Input01", "Input02", "Live TV"] - assert attrs[ATTR_MEDIA_CONTENT_TYPE] == MediaType.CHANNEL - assert attrs[ATTR_MEDIA_TITLE] == "Channel 1" - assert attrs[ATTR_SOUND_OUTPUT] == "speaker" + assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - monkeypatch.setattr(client, "volume", None) + client.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - monkeypatch.setattr(client, "current_channel", CHANNEL_2) + client.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -334,17 +319,11 @@ async def test_entity_attributes( # Device Info device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) - - assert device - assert device.identifiers == {(DOMAIN, entry.unique_id)} - assert device.manufacturer == "LG" - assert device.name == TV_NAME - assert device.sw_version == "major.minor" - assert device.model == "TVFAKE" + assert device == snapshot # Sound output when off - monkeypatch.setattr(client, "sound_output", None) - monkeypatch.setattr(client, "is_on", False) + client.sound_output = None + client.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -388,9 +367,7 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) -async def test_update_sources_live_tv_find( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) await client.mock_state_update() @@ -402,14 +379,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is current app - apps = { + client.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - monkeypatch.setattr(client, "apps", apps) - monkeypatch.setattr(client, "current_app_id", "some_id") + client.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -417,14 +393,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is is in inputs - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": LIVE_TV_APP_ID, }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -432,14 +407,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV is current input - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": "some_id", }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -447,7 +421,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found - monkeypatch.setattr(client, "current_app_id", "other_id") + client.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -455,8 +429,8 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found in sources/apps but is current app - monkeypatch.setattr(client, "apps", {}) - monkeypatch.setattr(client, "current_app_id", LIVE_TV_APP_ID) + client.apps = {} + client.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -464,7 +438,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Bad update, keep old update - monkeypatch.setattr(client, "inputs", {}) + client.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -473,26 +447,47 @@ async def test_update_sources_live_tv_find( async def test_client_disconnected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + client, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test error not raised when client is disconnected.""" await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=TimeoutError)) + client.is_connected.return_value = False + client.connect.side_effect = TimeoutError + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "TimeoutError" not in caplog.text - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + +async def test_client_key_update_on_connect( + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory +) -> None: + """Test client key update upon connect.""" + config_entry = await setup_webostv(hass) + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + client.is_connected.return_value = False + client.client_key = "new_key" + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + async def test_control_error_handling( - hass: HomeAssistant, - client, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture ) -> None: """Test control errors handling.""" await setup_webostv(hass) - monkeypatch.setattr(client, "play", Mock(side_effect=WebOsTvCommandError)) + client.play.side_effect = WebOsTvCommandError data = {ATTR_ENTITY_ID: ENTITY_ID} # Device on, raise HomeAssistantError @@ -506,23 +501,21 @@ async def test_control_error_handling( assert client.play.call_count == 1 # Device off, log a warning - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "play", Mock(side_effect=TimeoutError)) + client.is_on = False + client.play.side_effect = TimeoutError await client.mock_state_update() await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert client.play.call_count == 1 + assert client.play.call_count == 2 assert ( f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error:" " TimeoutError()" in caplog.text ) -async def test_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "sound_output", "lineout") + client.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -533,7 +526,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -541,7 +534,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - monkeypatch.setattr(client, "sound_output", "speaker") + client.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -577,12 +570,10 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported -async def test_cached_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -610,8 +601,8 @@ async def test_cached_supported_features( ) # TV on, support volume mute, step - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.is_on = True + client.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -620,8 +611,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -630,8 +621,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "speaker") + client.is_on = True + client.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -642,8 +633,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = ( @@ -684,12 +675,10 @@ async def test_cached_supported_features( ) -async def test_supported_features_no_cache( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await setup_webostv(hass) supported = ( @@ -729,11 +718,10 @@ async def test_get_image_http( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -755,11 +743,10 @@ async def test_get_image_http_error( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -782,11 +769,10 @@ async def test_get_image_https( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -803,16 +789,17 @@ async def test_get_image_https( async def test_reauth_reconnect( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory ) -> None: """Test reauth flow triggered by reconnect.""" entry = await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError assert entry.state is ConfigEntryState.LOADED - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -829,27 +816,22 @@ async def test_reauth_reconnect( assert flow["context"].get("entry_id") == entry.entry_id -async def test_update_media_state( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - data = {"foregroundAppInfo": [{"playState": "playing"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "playing"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - data = {"foregroundAppInfo": [{"playState": "paused"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "paused"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - data = {"foregroundAppInfo": [{"playState": "unloaded"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "unloaded"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - monkeypatch.setattr(client, "is_on", False) + client.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 75c2e148310393..b12cd0c7c6cfa0 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,6 +1,6 @@ """The tests for the WebOS TV notify platform.""" -from unittest.mock import Mock, call +from unittest.mock import call from aiowebostv import WebOsTvPairError import pytest @@ -14,22 +14,24 @@ from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import slugify from . import setup_webostv from .const import TV_NAME ICON_PATH = "/some/path" MESSAGE = "one, two, testing, testing" +SERVICE_NAME = slugify(TV_NAME) async def test_notify(hass: HomeAssistant, client) -> None: """Test sending a message.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -44,7 +46,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -59,7 +61,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: "only message, no data", }, @@ -72,17 +74,15 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_notify_not_connected(hass: HomeAssistant, client) -> None: """Test sending a message when client is not connected.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + client.is_connected.return_value = False await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -97,19 +97,16 @@ async def test_notify_not_connected( async def test_icon_not_found( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: """Test notify icon not found error.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) + client.send_message.side_effect = FileNotFoundError await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -132,22 +129,17 @@ async def test_icon_not_found( ], ) async def test_connection_errors( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, - side_effect, - error, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client, side_effect, error ) -> None: """Test connection errors scenarios.""" await setup_webostv(hass) - assert hass.services.has_service("notify", TV_NAME) + assert hass.services.has_service("notify", SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.is_connected.return_value = False + client.connect.side_effect = side_effect await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -157,7 +149,7 @@ async def test_connection_errors( blocking=True, ) assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 + assert client.connect.call_count == 2 client.send_message.assert_not_called() assert error in caplog.text @@ -175,4 +167,4 @@ async def test_no_discovery_info( await hass.async_block_till_done() assert NOTIFY_DOMAIN in hass.config.components assert f"Failed to initialize notification service {DOMAIN}" in caplog.text - assert not hass.services.has_service("notify", TV_NAME) + assert not hass.services.has_service("notify", SERVICE_NAME) diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 1bbe91fc573c90..dbdeb0726dd513 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -81,7 +81,7 @@ def mock_user_id() -> Generator[AsyncMock]: """Mock the user API call.""" with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as user_mock, ): @@ -93,7 +93,7 @@ def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: """Mock an Weheat discovery.""" with ( patch( - "homeassistant.components.weheat.HeatPumpDiscovery.discover_active", + "homeassistant.components.weheat.HeatPumpDiscovery.async_discover_active", autospec=True, ) as mock_discover, ): diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py index e75cb282e2460e..5769fc9a1a808b 100644 --- a/tests/components/weheat/test_binary_sensor.py +++ b/tests/components/weheat/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -40,7 +39,6 @@ async def test_create_binary_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test creating entities.""" mock_weheat_discover.return_value = [mock_heat_pump_info] diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index b33dd0a8db89ea..45f2285fd03a18 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as mock_weheat, ): @@ -89,7 +89,7 @@ async def test_duplicate_unique_id( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ), ): diff --git a/tests/components/weheat/test_init.py b/tests/components/weheat/test_init.py new file mode 100644 index 00000000000000..af5e2b8411b60e --- /dev/null +++ b/tests/components/weheat/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the weheat initialization.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.components.weheat import UnauthorizedException +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import ClientResponseError + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup.""" + mock_weheat_discover.return_value = [mock_heat_pump_info] + + await setup_integration(hass, mock_config_entry) + + 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 is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize( + ("setup_exception", "expected_setup_state"), + [ + (HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.FORBIDDEN, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.GATEWAY_TIMEOUT, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_fail( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, + setup_exception: Exception, + expected_setup_state: ConfigEntryState, +) -> None: + """Test the Weheat setup with invalid token setup.""" + with ( + patch( + "homeassistant.components.weheat.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + Mock(real_url="http://example.com"), None, status=setup_exception + ), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_setup_state + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_fail_discover( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup with and error from the heat pump discovery.""" + mock_weheat_discover.side_effect = UnauthorizedException() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index 062b84d042370c..f3eec282704937 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -41,7 +40,6 @@ async def test_create_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, has_dhw: bool, nr_of_entities: int, ) -> None: diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index acaf2aef2a8e7b..8b32d7b1633e13 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -2,19 +2,19 @@ from pywilight.const import DOMAIN -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL, -) from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ UPNP_MANUFACTURER_NOT_WILIGHT = "Test" CONF_COMPONENTS = "components" -MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_P_B = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -46,7 +46,7 @@ }, ) -MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -58,7 +58,7 @@ }, ) -MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d0ad5b2659a2e5..b61a54150e4a85 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -4,12 +4,12 @@ import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_integration from .conftest import CLIENT_ID, USER_ID diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb46cf..d88af39488b2d1 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index c60e080f6d440a..ddf4a4f452afaf 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -6,13 +6,13 @@ from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.wiz.config_flow import CONF_DEVICE from homeassistant.components.wiz.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( FAKE_DIMMABLE_BULB, @@ -32,7 +32,7 @@ from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="wiz_abcabc", ip=FAKE_IP, macaddress=FAKE_MAC, diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index a1cf515a24b8d3..15db188af5eeee 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,12 +6,12 @@ import pytest from wled import WLEDConnectionError -from homeassistant.components import zeroconf from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -43,7 +43,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -87,7 +87,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -132,7 +132,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -175,7 +175,7 @@ async def test_zeroconf_without_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 782dc051c8cfbc..2c628bbc296518 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -4,12 +4,12 @@ import aiohttp -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_config_entry diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 6bca226d6213ed..30faa2dd441f54 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -9,10 +9,10 @@ from homeassistant import config_entries from homeassistant.components.wyoming.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO @@ -178,11 +178,11 @@ async def test_hassio_addon_discovery( async def test_hassio_addon_already_configured(hass: HomeAssistant) -> None: """Test we abort discovery if the add-on is already configured.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, - data={"host": "mock-piper", "port": "10200"}, - unique_id="1234", - ).add_to_hass(hass) + data={"host": "mock-piper", "port": 10200}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -191,6 +191,7 @@ async def test_hassio_addon_already_configured(hass: HomeAssistant) -> None: ) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" + assert entry.unique_id == "1234" async def test_hassio_addon_cannot_connect(hass: HomeAssistant) -> None: @@ -297,3 +298,29 @@ async def test_zeroconf_discovery_no_services( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "no_services" + + +async def test_zeroconf_discovery_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "127.0.0.1", "port": 12345}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert entry.unique_id == "test_zeroconf_name._wyoming._tcp.local._Test Satellite" diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 141e245815e6ee..eb5cf976cb8688 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -7,11 +7,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" @@ -409,7 +409,7 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -456,7 +456,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -476,7 +476,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 218a382ada5f8c..f415a968f25bce 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -52,7 +52,7 @@ async def test_event_button_press(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -78,7 +78,7 @@ async def test_event_unlock_outside_the_door(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) @@ -104,7 +104,7 @@ async def test_event_successful_fingerprint_match_the_door(hass: HomeAssistant) hass, make_advertisement( mac, - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -153,7 +153,7 @@ async def test_event_dimmer_rotate(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak( hass, make_advertisement( - mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6" + mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6" ), ) @@ -182,7 +182,7 @@ async def test_get_triggers_button( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -406,7 +406,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01\x08\x12\x05\x00\x00\x00q^\xbe\x90", ), ) @@ -442,7 +442,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py index 1de5859c35e208..7f31fe048aa705 100644 --- a/tests/components/xiaomi_ble/test_event.py +++ b/tests/components/xiaomi_ble/test_event.py @@ -23,8 +23,7 @@ "54:EF:44:E3:9C:BC", make_advertisement( "54:EF:44:E3:9C:BC", - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' - b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), "5b51a7c91cde6707c9ef18dfda143a58", [ @@ -114,7 +113,7 @@ "F8:24:41:C5:98:8B", make_advertisement( "F8:24:41:C5:98:8B", - b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6", + b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6", ), "b853075158487ca39a5b5ea9", [ @@ -221,7 +220,7 @@ async def test_xiaomi_fingerprint(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -264,7 +263,7 @@ async def test_xiaomi_lock(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 146526c69a5a40..92fe53a8fc7e98 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,11 +9,11 @@ import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import TEST_MAC @@ -434,7 +434,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -477,7 +477,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -497,7 +497,7 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -517,7 +517,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -801,7 +801,7 @@ async def zeroconf_device_success( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 7629d2401c2e7e..51645dee49e18f 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -7,12 +7,16 @@ import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.yamaha_musiccast.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -103,7 +107,7 @@ def mock_valid_discovery_information(): with patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[ - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", @@ -265,13 +269,13 @@ async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "123456789", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "123456789", }, ), ) @@ -287,13 +291,13 @@ async def test_ssdp_discovery_successful_add_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) @@ -329,13 +333,13 @@ async def test_ssdp_discovery_existing_device_update( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index bdd8cdda312acc..f534b214b1c980 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -9,7 +9,6 @@ from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -21,6 +20,7 @@ ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo FAIL_TO_BIND_IP = "1.2.3.4" @@ -43,7 +43,7 @@ ID_DECIMAL = f"{int(ID, 16):08d}" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], port=54321, diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1acb553af3d6f4..a3f83cc03aa2ef 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.yeelight.config_flow import ( MODEL_UNKNOWN, CannotConnect, @@ -30,6 +29,12 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import ( CAPABILITIES, @@ -57,7 +62,7 @@ CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } -SSDP_INFO = ssdp.SsdpServiceInfo( +SSDP_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, @@ -493,13 +498,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -525,7 +530,7 @@ def is_matching(self, other_flow) -> bool: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ) @@ -543,7 +548,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -560,7 +565,7 @@ def is_matching(self, other_flow) -> bool: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -574,19 +579,19 @@ def is_matching(self, other_flow) -> bool: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -648,19 +653,19 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -894,19 +899,19 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index be78964f23172f..3586f54a59a36d 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -24,9 +24,18 @@ from homeassistant.core import HomeAssistant from homeassistant.generated import zeroconf as zc_gen from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import ATTR_COMPONENT, async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import ( + MockConfigEntry, + MockModule, + import_and_test_deprecated_constant, + mock_integration, +) NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1655,3 +1664,35 @@ def http_only_service_update_mock(zeroconf, services, handlers): assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "ATTR_PROPERTIES_ID", + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + ATTR_PROPERTIES_ID, + ), + ( + "ZeroconfServiceInfo", + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + ZeroconfServiceInfo, + ), + ], +) +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, + zeroconf, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e0229ebe049d6d..573a04e9b5740f 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -20,9 +20,7 @@ import zigpy.types from homeassistant import config_entries -from homeassistant.components import ssdp, usb, zeroconf from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( CONF_BAUDRATE, @@ -43,6 +41,13 @@ from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -162,7 +167,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-cc2652-poe", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -175,7 +180,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -185,7 +190,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-efr32-poe", "tubeszb-efr32-poe", RadioType.ezsp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-efr32-poe.local.", @@ -198,7 +203,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -208,7 +213,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "TubeZB", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -229,7 +234,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "Some Zigbee Gateway (12345)", "aabbccddeeff", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some-zigbee-gateway-12345.local.", @@ -248,7 +253,7 @@ async def test_zeroconf_discovery( entry_name: str, unique_id: str, radio_type: RadioType, - service_info: zeroconf.ZeroconfServiceInfo, + service_info: ZeroconfServiceInfo, hass: HomeAssistant, ) -> None: """Test zeroconf flow -- radio detected.""" @@ -290,7 +295,7 @@ async def test_legacy_zeroconf_discovery_zigate( setup_entry_mock, hass: HomeAssistant ) -> None: """Test zeroconf flow -- zigate radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway.local.", @@ -339,7 +344,7 @@ async def test_legacy_zeroconf_discovery_zigate( async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: """Test zeroconf flow with a bad payload.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some.hostname", @@ -367,7 +372,7 @@ async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) ) entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -397,7 +402,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( hass: HomeAssistant, ) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", @@ -429,7 +434,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -475,7 +480,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: """Test zigate usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="0403", vid="6015", @@ -528,7 +533,7 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None ) async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/null", pid="AAAA", vid="AAAA", @@ -561,7 +566,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -595,7 +600,7 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) entry.add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -622,7 +627,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) """Test usb flow -- deconz discovered.""" result = await hass.config_entries.flow.async_init( "deconz", - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -634,7 +639,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -656,7 +661,7 @@ async def test_discovery_via_usb_deconz_already_setup(hass: HomeAssistant) -> No """Test usb flow -- deconz setup.""" MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -680,7 +685,7 @@ async def test_discovery_via_usb_deconz_ignored(hass: HomeAssistant) -> None: domain="deconz", source=config_entries.SOURCE_IGNORE, data={} ).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -708,7 +713,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non ) entry.add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -732,7 +737,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", @@ -1196,7 +1201,7 @@ async def test_onboarding_auto_formation_new_hardware( """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py new file mode 100644 index 00000000000000..add98bb96bfd93 --- /dev/null +++ b/tests/components/zha/test_entity.py @@ -0,0 +1,47 @@ +"""Test ZHA entities.""" + +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +async def test_device_registry_via_device( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test ZHA `via_device` is set correctly.""" + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + reg_coordinator_device = device_registry.async_get_device( + identifiers={("zha", str(gateway.state.node_info.ieee))} + ) + + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) + + assert reg_device.via_device_id == reg_coordinator_device.id diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 0a51aaa6dba718..59494dd0d09361 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -11,12 +11,12 @@ from zigpy.config import CONF_DEVICE_PATH import zigpy.types -from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 37b1dde7316953..bcdc0c3ce16114 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -133,9 +133,9 @@ def climate_radio_thermostat_ct100_plus_state_fixture() -> dict[str, Any]: name="climate_radio_thermostat_ct100_plus_different_endpoints_state", scope="package", ) -def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> dict[ + str, Any +]: """Load the thermostat fixture state with values on different endpoints. This device is a radio thermostat ct100. @@ -336,9 +336,9 @@ def lock_id_lock_as_id150_state_fixture() -> dict[str, Any]: @pytest.fixture( name="climate_radio_thermostat_ct101_multiple_temp_units_state", scope="package" ) -def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> dict[ + str, Any +]: """Load the climate multiple temp units node state fixture data.""" return load_json_object_fixture( "climate_radio_thermostat_ct101_multiple_temp_units_state.json", DOMAIN diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index b60515cacd45c3..e7239c23de6b29 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -16,13 +16,13 @@ from zwave_js_server.version import VersionInfo from homeassistant import config_entries -from homeassistant.components import usb -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -33,7 +33,7 @@ } -USB_DISCOVERY_INFO = usb.UsbServiceInfo( +USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", vid="AAAA", @@ -42,7 +42,7 @@ manufacturer="test", ) -NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +NORTEK_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="8A2A", vid="10C4", @@ -51,7 +51,7 @@ manufacturer="nortek", ) -CP2652_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="EA60", vid="10C4", diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index a71df8751b6cfd..f784d7db2db327 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -4,14 +4,14 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.zwave_me.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.14"), ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", diff --git a/tests/conftest.py b/tests/conftest.py index 2cefe72f41487b..3195e6918b9f2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch from aiohttp import client +from aiohttp.resolver import AsyncResolver from aiohttp.test_utils import ( BaseTestServer, TestClient, @@ -32,6 +33,7 @@ import freezegun import multidict import pytest +import pytest_asyncio import pytest_socket import requests_mock import respx @@ -42,7 +44,7 @@ from homeassistant.exceptions import ServiceNotFound # Setup patching of recorder functions before any other Home Assistant imports -from . import patch_recorder # noqa: F401, isort:skip +from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip @@ -411,7 +413,9 @@ def verify_cleanup( try: # Verify respx.mock has been cleaned up - assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + assert not respx.mock.routes, ( + "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + ) finally: # Clear mock routes not break subsequent tests respx.mock.clear() @@ -579,7 +583,7 @@ def exc_handle(loop, context): exceptions.append( Exception( "Received exception handler without exception, " - f"but with message: {context["message"]}" + f"but with message: {context['message']}" ) ) orig_exception_handler(loop, context) @@ -923,7 +927,13 @@ def log_exception(format_err, *args): @pytest.fixture def mqtt_config_entry_data() -> dict[str, Any] | None: - """Fixture to allow overriding MQTT config.""" + """Fixture to allow overriding MQTT entry data.""" + return None + + +@pytest.fixture +def mqtt_config_entry_options() -> dict[str, Any] | None: + """Fixture to allow overriding MQTT entry options.""" return None @@ -1000,6 +1010,7 @@ async def mqtt_mock( mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1011,6 +1022,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1018,17 +1030,19 @@ async def _mqtt_mock_entry( from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel if mqtt_config_entry_data is None: - mqtt_config_entry_data = { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - } + mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} + if mqtt_config_entry_options is None: + mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}} await hass.async_block_till_done() entry = MockConfigEntry( data=mqtt_config_entry_data, + options=mqtt_config_entry_options, domain=mqtt.DOMAIN, title="MQTT", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -1044,7 +1058,6 @@ async def _setup_mqtt_entry( # Assert that MQTT is setup assert real_mqtt_instance is not None, "MQTT was not setup correctly" - mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics mock_mqtt_instance._mqttc = mqtt_client_mock # connected set to True to get a more realistic behavior when subscribing @@ -1139,6 +1152,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1155,7 +1169,7 @@ async def _setup_mqtt_entry() -> MqttMockHAClient: return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data + hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options ) as mqtt_mock_entry: yield _setup_mqtt_entry @@ -1220,6 +1234,30 @@ def disable_translations_once( translations_once.start() +@pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") +async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: + """Mock out the zeroconf resolver.""" + patcher = patch( + "homeassistant.helpers.aiohttp_client._async_make_resolver", + return_value=AsyncResolver(), + ) + patcher.start() + try: + yield patcher + finally: + patcher.stop() + + +@pytest.fixture +def disable_mock_zeroconf_resolver( + mock_zeroconf_resolver: _patch, +) -> Generator[None]: + """Disable the zeroconf resolver.""" + mock_zeroconf_resolver.stop() + yield + mock_zeroconf_resolver.start() + + @pytest.fixture def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 1788da74c3b846..3fb83ae5781fbd 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -390,3 +390,16 @@ async def test_client_session_immutable_headers(hass: HomeAssistant) -> None: with pytest.raises(AttributeError): session.headers.update({"user-agent": "bla"}) + + +@pytest.mark.usefixtures("disable_mock_zeroconf_resolver") +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_mdnsresolver( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test async_mdnsresolver.""" + resp = aioclient_mock.post("http://localhost/xyz", json={"x": 1}) + session = client.async_create_clientsession(hass) + resp = await session.post("http://localhost/xyz", json={"x": 1}) + assert resp.status == 200 + assert await resp.json() == {"x": 1} diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 74f55c86a6cbd6..c69f039027ef50 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -7,6 +7,13 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -18,6 +25,27 @@ from tests.common import ANY, async_capture_events, flush_store +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas(area_registry: ar.AreaRegistry) -> None: """Make sure that we can read areas.""" area_registry.async_create("mock") @@ -31,6 +59,7 @@ async def test_create_area( hass: HomeAssistant, freezer: FrozenDateTimeFactory, area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can create an area.""" update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) @@ -48,6 +77,8 @@ async def test_create_area( picture=None, created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id=None, + humidity_entity_id=None, ) assert len(area_registry.areas) == 1 @@ -67,6 +98,8 @@ async def test_create_area( aliases={"alias_1", "alias_2"}, labels={"label1", "label2"}, picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert area2 == ar.AreaEntry( @@ -79,6 +112,8 @@ async def test_create_area( picture="/image/example.png", created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 2 assert area.created_at != area2.created_at @@ -164,6 +199,7 @@ async def test_update_area( floor_registry: fr.FloorRegistry, label_registry: lr.LabelRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can read areas.""" created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") @@ -184,6 +220,8 @@ async def test_update_area( labels={"label1", "label2"}, name="mock1", picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert updated_area != area @@ -197,6 +235,8 @@ async def test_update_area( picture="/image/example.png", created_at=created_at, modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 1 @@ -274,6 +314,55 @@ async def test_update_area_with_normalized_name_already_in_use( assert len(area_registry.areas) == 2 +@pytest.mark.parametrize( + ("create_kwargs", "error_message"), + [ + ( + {"temperature_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"temperature_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a temperature sensor", + ), + ( + {"temperature_entity_id": "sensor.random"}, + "Entity sensor.random is not a temperature sensor", + ), + ( + {"humidity_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"humidity_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a humidity sensor", + ), + ( + {"humidity_entity_id": "sensor.random"}, + "Entity sensor.random is not a humidity sensor", + ), + ], +) +async def test_update_area_entity_validation( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, + create_kwargs: dict[str, Any], + error_message: str, +) -> None: + """Make sure that we can't update an area with an invalid entity.""" + area = area_registry.async_create("mock") + hass.states.async_set("light.kitchen", "on", {}) + hass.states.async_set("sensor.random", "3", {}) + + with pytest.raises(ValueError) as e_info: + area_registry.async_update(area.id, **create_kwargs) + assert str(e_info.value) == error_message + + assert area.temperature_entity_id is None + assert area.humidity_entity_id is None + + async def test_load_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: """Make sure that we can load/save data correctly.""" area1 = area_registry.async_create("mock1") @@ -298,6 +387,8 @@ async def test_loading_area_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored areas on start.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") hass_storage[ar.STORAGE_KEY] = { "version": ar.STORAGE_VERSION_MAJOR, "minor_version": ar.STORAGE_VERSION_MINOR, @@ -311,8 +402,10 @@ async def test_loading_area_from_storage( "labels": ["mock-label1", "mock-label2"], "name": "mock", "picture": "blah", - "created_at": utcnow().isoformat(), - "modified_at": utcnow().isoformat(), + "created_at": created_at.isoformat(), + "modified_at": modified_at.isoformat(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } ] }, @@ -322,6 +415,20 @@ async def test_loading_area_from_storage( registry = ar.async_get(hass) assert len(registry.areas) == 1 + area = registry.areas["12345A"] + assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + floor_id="first_floor", + icon="mdi:garage", + id="12345A", + labels={"mock-label1", "mock-label2"}, + name="mock", + picture="blah", + created_at=created_at, + modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", + ) @pytest.mark.parametrize("load_registries", [False]) @@ -359,6 +466,8 @@ async def test_migration_from_1_1( "picture": None, "created_at": "1970-01-01T00:00:00+00:00", "modified_at": "1970-01-01T00:00:00+00:00", + "temperature_entity_id": None, + "humidity_entity_id": None, } ] }, diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840bcb..172aa393538768 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,20 +15,42 @@ from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + + +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf + handler_conf = {"discovered": False} @pytest.fixture @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4cf7e851af35f1..a74055c59ec3cd 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -295,7 +295,7 @@ def _get_value( return obj.value if isinstance(obj, DeprecatedConstantEnum): - return obj.enum.value + return obj.enum if isinstance(obj, DeprecatedAlias): return obj.value diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index cf7bbe7d1e25d2..08b984a047747a 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2276,7 +2276,9 @@ async def test_cleanup_startup(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: +async def test_cleanup_entity_registry_change( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test we run a cleanup when entity registry changes. Don't pre-load the registries as the debouncer will then not be waiting for @@ -2284,8 +2286,14 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: """ await dr.async_load(hass) await er.async_load(hass) + dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) + entry = dev_reg.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + with patch( "homeassistant.helpers.device_registry.Debouncer.async_schedule_call" ) as mock_call: @@ -2299,7 +2307,7 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 0 # Device ID update triggers - ent_reg.async_get_or_create("light", "hue", "e1", device_id="bla") + ent_reg.async_get_or_create("light", "hue", "e1", device_id=entry.id) await hass.async_block_till_done() assert len(mock_call.mock_calls) == 1 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index dc579ab6e8d76f..2bf441f70fd22b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,6 +4,7 @@ from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any @@ -2485,6 +2486,31 @@ def _attr_attribution(self): return "🤡" +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) + + async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e80006dff84a74..7c9244583e99d8 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -869,6 +869,7 @@ async def async_setup_entry( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1886,6 +1887,7 @@ async def async_setup_entry( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1934,6 +1936,7 @@ async def async_setup_entry( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 97f7e1dcc561f9..19289b09f9567d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -72,11 +72,18 @@ def test_get_or_create_suggested_object_id(entity_registry: er.EntityRegistry) - def test_get_or_create_updates_data( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test that we update data in get_or_create.""" orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry.add_to_hass(hass) + orig_device_entry = device_registry.async_get_or_create( + config_entry_id=orig_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) created = datetime.fromisoformat("2024-02-14T12:00:00.0+00:00") freezer.move_to(created) @@ -86,7 +93,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"max": 100}, config_entry=orig_config_entry, - device_id="mock-dev-id", + device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, has_entity_name=True, @@ -99,7 +106,7 @@ def test_get_or_create_updates_data( unit_of_measurement="initial-unit_of_measurement", ) - assert set(entity_registry.async_device_ids()) == {"mock-dev-id"} + assert set(entity_registry.async_device_ids()) == {orig_device_entry.id} assert orig_entry == er.RegistryEntry( "light.hue_5678", @@ -109,7 +116,7 @@ def test_get_or_create_updates_data( config_entry_id=orig_config_entry.entry_id, created_at=created, device_class=None, - device_id="mock-dev-id", + device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, has_entity_name=True, @@ -127,6 +134,11 @@ def test_get_or_create_updates_data( ) new_config_entry = MockConfigEntry(domain="light") + new_config_entry.add_to_hass(hass) + new_device_entry = device_registry.async_get_or_create( + config_entry_id=new_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + ) modified = created + timedelta(minutes=5) freezer.move_to(modified) @@ -136,7 +148,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"new-max": 150}, config_entry=new_config_entry, - device_id="new-mock-dev-id", + device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.USER, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=False, @@ -159,7 +171,7 @@ def test_get_or_create_updates_data( config_entry_id=new_config_entry.entry_id, created_at=created, device_class=None, - device_id="new-mock-dev-id", + device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=False, @@ -176,7 +188,7 @@ def test_get_or_create_updates_data( unit_of_measurement="updated-unit_of_measurement", ) - assert set(entity_registry.async_device_ids()) == {"new-mock-dev-id"} + assert set(entity_registry.async_device_ids()) == {new_device_entry.id} modified = created + timedelta(minutes=5) freezer.move_to(modified) @@ -262,10 +274,18 @@ def test_create_triggers_save(entity_registry: er.EntityRegistry) -> None: async def test_loading_saving_data( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that we load/save data correctly.""" mock_config = MockConfigEntry(domain="light") + mock_config.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) orig_entry1 = entity_registry.async_get_or_create("light", "hue", "1234") orig_entry2 = entity_registry.async_get_or_create( @@ -274,7 +294,7 @@ async def test_loading_saving_data( "5678", capabilities={"max": 100}, config_entry=mock_config, - device_id="mock-dev-id", + device_id=device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, @@ -338,7 +358,7 @@ async def test_loading_saving_data( assert new_entry2.capabilities == {"max": 100} assert new_entry2.config_entry_id == mock_config.entry_id assert new_entry2.device_class == "user-class" - assert new_entry2.device_id == "mock-dev-id" + assert new_entry2.device_id == device_entry.id assert new_entry2.disabled_by is er.RegistryEntryDisabler.HASS assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" @@ -596,11 +616,13 @@ async def test_updating_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config_1.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_1 ) mock_config_2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config_2.add_to_hass(hass) entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_2 ) @@ -627,6 +649,7 @@ async def test_removing_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -650,11 +673,14 @@ async def test_removing_config_entry_id( async def test_deleted_entity_removing_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test that we update config entry id in registry on deleted entity.""" mock_config1 = MockConfigEntry(domain="light", entry_id="mock-id-1") mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config1.add_to_hass(hass) + mock_config2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config1 @@ -959,9 +985,12 @@ async def test_migration_1_11( } -async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -987,10 +1016,12 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No async def test_update_entity_unique_id_conflict( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test migration raises when unique_id already in use.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1079,9 +1110,12 @@ async def test_update_entity_entity_id_entity_id( assert entity_registry.async_get(state_entity_id) is None -async def test_update_entity(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1106,9 +1140,12 @@ async def test_update_entity(entity_registry: er.EntityRegistry) -> None: entry = updated_entry -async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_options( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1161,6 +1198,7 @@ async def test_disabled_by(entity_registry: er.EntityRegistry) -> None: async def test_disabled_by_config_entry_pref( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test config entry preference setting disabled_by.""" @@ -1169,6 +1207,7 @@ async def test_disabled_by_config_entry_pref( entry_id="mock-id-1", pref_disable_new_entities=True, ) + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "AAAA", config_entry=mock_config ) @@ -1741,6 +1780,35 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None +async def test_config_entry_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown config entry.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + pref_disable_new_entities=True, + ) + with pytest.raises(ValueError): + entity_registry.async_get_or_create( + "light", "hue", "1234", config_entry=mock_config + ) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity( + entity_id, config_entry_id=mock_config.entry_id + ) + + +async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown device.""" + with pytest.raises(ValueError): + entity_registry.async_get_or_create("light", "hue", "1234", device_id="blah") + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity(entity_id, device_id="blah") + + async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): @@ -1818,6 +1886,7 @@ def test_migrate_entity_to_new_platform( ) -> None: """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry.add_to_hass(hass) orig_unique_id = "5678" orig_entry = entity_registry.async_get_or_create( @@ -1840,6 +1909,7 @@ def test_migrate_entity_to_new_platform( ) new_config_entry = MockConfigEntry(domain="light") + new_config_entry.add_to_hass(hass) new_unique_id = "1234" assert entity_registry.async_update_entity_platform( @@ -1894,6 +1964,7 @@ async def test_restore_entity( """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) @@ -1988,6 +2059,8 @@ async def test_async_migrate_entry_delete_self( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) @@ -2023,6 +2096,8 @@ async def test_async_migrate_entry_delete_other( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3787526c433fcb..5348348bb0d235 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -39,6 +39,14 @@ def llm_context() -> llm.LLMContext: ) +class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "", [], llm_context) + + async def test_get_api_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -50,11 +58,6 @@ async def test_get_api_no_existing( async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test registering an llm api.""" - class MyAPI(llm.API): - async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: - """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) - api = MyAPI(hass=hass, id="test", name="Test") llm.async_register_api(hass, api) @@ -66,6 +69,59 @@ async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: llm.async_register_api(hass, api) +async def test_unregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_reregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api then re-registering with the same id.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_unregister_twice( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test unregistering an llm api twice.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + + # Unregistering twice is a bug that should not happen + with pytest.raises(KeyError): + unreg() + + +async def test_multiple_apis(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test registering multiple APIs.""" + + unreg1 = llm.async_register_api(hass, MyAPI(hass=hass, id="test-1", name="Test 1")) + llm.async_register_api(hass, MyAPI(hass=hass, id="test-2", name="Test 2")) + + # Verify both Apis are registered + assert await llm.async_get_api(hass, "test-1", llm_context) + assert await llm.async_get_api(hass, "test-2", llm_context) + + # Unregister and verify only one is left + unreg1() + + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test-1", llm_context) + + assert await llm.async_get_api(hass, "test-2", llm_context) + + async def test_call_tool_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c438e333ae630a..d7c00e90bd6c41 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 6d03e09cdf7fa0..f802d6ffa5a0eb 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -122,6 +122,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="test-floor", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_in_floor_a = ar.AreaEntry( id="area-a", @@ -130,6 +132,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="floor-a", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, @@ -284,6 +288,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels={"label_area"}, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_without_labels = ar.AreaEntry( id="area-no-labels", @@ -293,6 +299,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels=set(), picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 628aea20900ba2..37e886dddce396 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1970,7 +1970,7 @@ def test_is_state(hass: HomeAssistant) -> None: def test_is_state_attr(hass: HomeAssistant) -> None: """Test is_state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) tpl = template.Template( """ {% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %} @@ -2003,6 +2003,22 @@ def test_is_state_attr(hass: HomeAssistant) -> None: ) assert tpl.async_render() == "test.object" + tpl = template.Template( + """ +{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "no" + def test_state_attr(hass: HomeAssistant) -> None: """Test state_attr method.""" @@ -2110,6 +2126,7 @@ async def test_state_translated( hass.states.async_set("domain.is_unknown", "unknown", attributes={}) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( "light", "hue", diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index d4a78807e2b20c..3593db9cf8784a 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -74,7 +74,7 @@ def test_load_translations_files_by_language( "name": "Other 4", "unit_of_measurement": "quantities", }, - "outlet": {"name": "Outlet " "{placeholder}"}, + "outlet": {"name": "Outlet {placeholder}"}, } }, "something": "else", diff --git a/tests/patch_recorder.py b/tests/patch_recorder.py index 4993e84fc30174..e0e66de19a5de2 100644 --- a/tests/patch_recorder.py +++ b/tests/patch_recorder.py @@ -6,7 +6,7 @@ import sys # Patch recorder util session scope -from homeassistant.helpers import recorder as recorder_helper # noqa: E402 +from homeassistant.helpers import recorder as recorder_helper # Make sure homeassistant.components.recorder.util is not already imported assert "homeassistant.components.recorder.util" not in sys.modules diff --git a/tests/syrupy.py b/tests/syrupy.py index a3b3f76306369d..5b1e5faa23d4fa 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,6 +109,8 @@ def _serialize( serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) + elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) elif isinstance(data, ConfigEntry): @@ -200,6 +202,11 @@ def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: """Prepare a Home Assistant flow result for serialization.""" return FlowResultSnapshot(data | {"flow_id": ANY}) + @classmethod + def _serializable_conversation_result(cls, data: dict) -> SerializableData: + """Prepare a Home Assistant conversation result for serialization.""" + return data | {"conversation_id": ANY} + @classmethod def _serializable_issue_registry_entry( cls, data: ir.IssueEntry @@ -376,7 +383,7 @@ def override_syrupy_finish(self: SnapshotSession) -> int: with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) with open( - f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", "w", encoding="utf-8", ) as f: diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index dd23d4e9709bc9..f42fbb9f4efc99 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -261,7 +261,7 @@ async def test_protect_path_read_bytes(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_bytes(), # noqa: ASYNC230 + Path("/config/data_not_exist").read_bytes(), ): pass @@ -274,7 +274,7 @@ async def test_protect_path_read_text(caplog: pytest.LogCaptureFixture) -> None: block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_text(encoding="utf8"), # noqa: ASYNC230 + Path("/config/data_not_exist").read_text(encoding="utf8"), ): pass @@ -287,7 +287,7 @@ async def test_protect_path_write_bytes(caplog: pytest.LogCaptureFixture) -> Non block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_bytes(b"xxx"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_bytes(b"xxx"), ): pass @@ -300,7 +300,7 @@ async def test_protect_path_write_text(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), ): pass diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index a32d7d1e50bc95..c1c532c94b57b4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1392,9 +1392,13 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() + disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() + # zeroconf is a top level dep now + disallowed_integrations.remove("zeroconf") + # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in bootstrap.STAGE_1_INTEGRATIONS: + for integration in disallowed_integrations: assert f"homeassistant.components.{integration}" not in decoded_stdout diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aba85a35349d32..39860dc67c2030 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -16,7 +16,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, @@ -41,6 +40,7 @@ from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -2645,9 +2645,7 @@ class TestFlow(config_entries.ConfigFlow): VERSION = 1 - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> FlowResult: + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Test dhcp step.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -2683,7 +2681,7 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: discovery_result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=host, macaddress=unique_id, @@ -7266,9 +7264,9 @@ async def test_unique_id_collision_issues( mock_setup_entry = AsyncMock(return_value=True) for i in range(3): mock_integration( - hass, MockModule(f"test{i+1}", async_setup_entry=mock_setup_entry) + hass, MockModule(f"test{i + 1}", async_setup_entry=mock_setup_entry) ) - mock_platform(hass, f"test{i+1}.config_flow", None) + mock_platform(hass, f"test{i + 1}.config_flow", None) test2_group_1: list[MockConfigEntry] = [] test2_group_2: list[MockConfigEntry] = [] diff --git a/tests/test_core_config.py b/tests/test_core_config.py index dae50bae0970db..2723c8e7196f96 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -46,7 +46,7 @@ UnitSystem, ) -from .common import MockUser, async_capture_events +from .common import MockEntityPlatform, MockUser, async_capture_events def test_core_config_schema() -> None: @@ -181,7 +181,8 @@ def test_validate_stun_or_turn_url() -> None: invalid_urls = ( "custom_stun_server", "custom_stun_server:3478", - "bum:custom_stun_server:3478" "http://blah.com:80", + "bum:custom_stun_server:3478", + "http://blah.com:80", ) valid_urls = ( @@ -221,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity = Entity() entity.entity_id = "test.test" entity.hass = hass + entity.platform = MockEntityPlatform(hass) entity.schedule_update_ha_state() await hass.async_block_till_done() diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 9c123d93f62340..1336364f4cb359 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -435,10 +435,24 @@ ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.VOLT, 5e6, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-3, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-6, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MILLIVOLT, 0.005, UnitOfElectricPotential.VOLT), (5, UnitOfElectricPotential.MILLIVOLT, 5e3, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-6, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-9, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-3, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-9, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-12, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e9, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e6, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e3, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e-3, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e12, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e9, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e3, UnitOfElectricPotential.KILOVOLT), ], EnergyConverter: [ (10, UnitOfEnergy.MILLIWATT_HOUR, 0.00001, UnitOfEnergy.KILO_WATT_HOUR),